schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1766
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{cli → engine/phases}/probes.py +63 -70
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +153 -39
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +483 -367
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -55
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -765
  156. schemathesis/cli/output/short.py +0 -40
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1231
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -555
  184. schemathesis/runner/events.py +0 -309
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -986
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -315
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -184
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.5.dist-info/METADATA +0 -356
  219. schemathesis-3.25.5.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,7 @@
1
1
  """Schema mutations."""
2
+
2
3
  from __future__ import annotations
4
+
3
5
  import enum
4
6
  from dataclasses import dataclass
5
7
  from functools import wraps
@@ -9,7 +11,8 @@ from hypothesis import reject
9
11
  from hypothesis import strategies as st
10
12
  from hypothesis.strategies._internal.featureflags import FeatureStrategy
11
13
 
12
- from ....internal.copy import fast_deepcopy
14
+ from schemathesis.core.transforms import deepclone
15
+
13
16
  from ..utils import get_type, is_header_location
14
17
  from .types import Draw, Schema
15
18
  from .utils import can_negate
@@ -79,6 +82,10 @@ class MutationContext:
79
82
  def is_path_location(self) -> bool:
80
83
  return self.location == "path"
81
84
 
85
+ @property
86
+ def is_query_location(self) -> bool:
87
+ return self.location == "query"
88
+
82
89
  def mutate(self, draw: Draw) -> Schema:
83
90
  # On the top level, Schemathesis creates "object" schemas for all parameter "in" values except "body", which is
84
91
  # taken as-is. Therefore, we can only apply mutations that won't change the Open API semantics of the schema.
@@ -105,7 +112,7 @@ class MutationContext:
105
112
  # Body can be of any type and does not have any specific type semantic.
106
113
  mutations = draw(ordered(get_mutations(draw, self.keywords)))
107
114
  # Deep copy all keywords to avoid modifying the original schema
108
- new_schema = fast_deepcopy(self.keywords)
115
+ new_schema = deepclone(self.keywords)
109
116
  enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations")) # type: ignore
110
117
  # Always apply at least one mutation, otherwise everything is rejected, and we'd like to avoid it
111
118
  # for performance reasons
@@ -173,7 +180,7 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
173
180
  else:
174
181
  candidate = draw(st.sampled_from(sorted(required)))
175
182
  enabled_properties = draw(st.shared(FeatureStrategy(), key="properties")) # type: ignore
176
- candidates = [candidate] + sorted([prop for prop in required if enabled_properties.is_enabled(prop)])
183
+ candidates = [candidate, *sorted([prop for prop in required if enabled_properties.is_enabled(prop)])]
177
184
  property_name = draw(st.sampled_from(candidates))
178
185
  required.remove(property_name)
179
186
  if not required:
@@ -201,8 +208,11 @@ def change_type(context: MutationContext, draw: Draw, schema: Schema) -> Mutatio
201
208
  if context.media_type == "application/x-www-form-urlencoded":
202
209
  # Form data should be an object, do not change it
203
210
  return MutationResult.FAILURE
204
- # Headers are always strings, can't negate this
205
- if context.is_header_location:
211
+ # For headers, query and path parameters, if the current type is string, then it already
212
+ # includes all possible values as those parameters will be stringified before sending,
213
+ # therefore it can't be negated.
214
+ types = get_type(schema)
215
+ if "string" in types and (context.is_header_location or context.is_path_location or context.is_query_location):
206
216
  return MutationResult.FAILURE
207
217
  candidates = _get_type_candidates(context, schema)
208
218
  if not candidates:
@@ -217,9 +227,10 @@ def change_type(context: MutationContext, draw: Draw, schema: Schema) -> Mutatio
217
227
  candidate = draw(st.sampled_from(sorted(candidates)))
218
228
  candidates.remove(candidate)
219
229
  enabled_types = draw(st.shared(FeatureStrategy(), key="types")) # type: ignore
220
- remaining_candidates = [candidate] + sorted(
221
- [candidate for candidate in candidates if enabled_types.is_enabled(candidate)]
222
- )
230
+ remaining_candidates = [
231
+ candidate,
232
+ *sorted([candidate for candidate in candidates if enabled_types.is_enabled(candidate)]),
233
+ ]
223
234
  new_type = draw(st.sampled_from(remaining_candidates))
224
235
  schema["type"] = new_type
225
236
  prevent_unsatisfiable_schema(schema, new_type)
@@ -362,6 +373,11 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
362
373
  # Should we negate this key?
363
374
  if k == "required":
364
375
  return v != []
376
+ if k in ("example", "examples"):
377
+ return False
378
+ if context.is_path_location and k == "minLength" and v == 1:
379
+ # Empty path parameter will be filtered out
380
+ return False
365
381
  return not (
366
382
  k in ("type", "properties", "items", "minItems")
367
383
  or (k == "additionalProperties" and context.is_header_location)
@@ -1,13 +1,17 @@
1
1
  from __future__ import annotations
2
+
2
3
  import json
3
4
  from dataclasses import dataclass
4
- from typing import Any, ClassVar, Iterable
5
+ from typing import TYPE_CHECKING, Any, ClassVar, Iterable
6
+
7
+ from schemathesis.core.errors import InvalidSchema
8
+ from schemathesis.schemas import Parameter
5
9
 
6
- from ...exceptions import OperationSchemaError
7
- from ...models import APIOperation
8
- from ...parameters import Parameter
9
10
  from .converter import to_json_schema_recursive
10
11
 
12
+ if TYPE_CHECKING:
13
+ from ...schemas import APIOperation
14
+
11
15
 
12
16
  @dataclass(eq=False)
13
17
  class OpenAPIParameter(Parameter):
@@ -18,6 +22,8 @@ class OpenAPIParameter(Parameter):
18
22
  nullable_field: ClassVar[str]
19
23
  supported_jsonschema_keywords: ClassVar[tuple[str, ...]]
20
24
 
25
+ def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
26
+
21
27
  @property
22
28
  def description(self) -> str | None:
23
29
  """A brief parameter description."""
@@ -47,16 +53,26 @@ class OpenAPIParameter(Parameter):
47
53
 
48
54
  @property
49
55
  def is_header(self) -> bool:
50
- raise NotImplementedError
56
+ return self.location in ("header", "cookie")
51
57
 
52
- def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
58
+ def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
53
59
  """Convert parameter's definition to JSON Schema."""
60
+ # JSON Schema allows `examples` as an array
61
+ examples = []
62
+ if self.examples_field in self.definition:
63
+ examples.extend(
64
+ [example["value"] for example in self.definition[self.examples_field].values() if "value" in example]
65
+ )
66
+ if self.example_field in self.definition:
67
+ examples.append(self.definition[self.example_field])
54
68
  schema = self.from_open_api_to_json_schema(operation, self.definition)
55
- return self.transform_keywords(schema)
69
+ if examples:
70
+ schema["examples"] = examples
71
+ return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
56
72
 
57
- def transform_keywords(self, schema: dict[str, Any]) -> dict[str, Any]:
73
+ def transform_keywords(self, schema: dict[str, Any], *, update_quantifiers: bool = True) -> dict[str, Any]:
58
74
  """Transform Open API specific keywords into JSON Schema compatible form."""
59
- definition = to_json_schema_recursive(schema, self.nullable_field)
75
+ definition = to_json_schema_recursive(schema, self.nullable_field, update_quantifiers=update_quantifiers)
60
76
  # Headers are strings, but it is not always explicitly defined in the schema. By preparing them properly, we
61
77
  # can achieve significant performance improvements for such cases.
62
78
  # For reference (my machine) - running a single test with 100 examples with the resulting strategy:
@@ -116,12 +132,10 @@ class OpenAPI20Parameter(OpenAPIParameter):
116
132
  "uniqueItems",
117
133
  "enum",
118
134
  "multipleOf",
135
+ "example",
136
+ "examples",
119
137
  )
120
138
 
121
- @property
122
- def is_header(self) -> bool:
123
- return self.location == "header"
124
-
125
139
 
126
140
  @dataclass(eq=False)
127
141
  class OpenAPI30Parameter(OpenAPIParameter):
@@ -162,12 +176,10 @@ class OpenAPI30Parameter(OpenAPIParameter):
162
176
  "properties",
163
177
  "additionalProperties",
164
178
  "format",
179
+ "example",
180
+ "examples",
165
181
  )
166
182
 
167
- @property
168
- def is_header(self) -> bool:
169
- return self.location in ("header", "cookie")
170
-
171
183
  def from_open_api_to_json_schema(self, operation: APIOperation, open_api_schema: dict[str, Any]) -> dict[str, Any]:
172
184
  open_api_schema = get_parameter_schema(operation, open_api_schema)
173
185
  return super().from_open_api_to_json_schema(operation, open_api_schema)
@@ -216,15 +228,17 @@ class OpenAPI20Body(OpenAPIBody, OpenAPI20Parameter):
216
228
  "allOf",
217
229
  "properties",
218
230
  "additionalProperties",
231
+ "example",
232
+ "examples",
219
233
  )
220
234
  # NOTE. For Open API 2.0 bodies, we still give `x-example` precedence over the schema-level `example` field to keep
221
235
  # the precedence rules consistent.
222
236
 
223
- def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
237
+ def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
224
238
  """Convert body definition to JSON Schema."""
225
239
  # `schema` is required in Open API 2.0 when the `in` keyword is `body`
226
240
  schema = self.definition["schema"]
227
- return self.transform_keywords(schema)
241
+ return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
228
242
 
229
243
 
230
244
  FORM_MEDIA_TYPES = ("multipart/form-data", "application/x-www-form-urlencoded")
@@ -243,13 +257,13 @@ class OpenAPI30Body(OpenAPIBody, OpenAPI30Parameter):
243
257
  required: bool = False
244
258
  description: str | None = None
245
259
 
246
- def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
260
+ def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
247
261
  """Convert body definition to JSON Schema."""
248
262
  schema = get_media_type_schema(self.definition)
249
- return self.transform_keywords(schema)
263
+ return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
250
264
 
251
- def transform_keywords(self, schema: dict[str, Any]) -> dict[str, Any]:
252
- definition = super().transform_keywords(schema)
265
+ def transform_keywords(self, schema: dict[str, Any], *, update_quantifiers: bool = True) -> dict[str, Any]:
266
+ definition = super().transform_keywords(schema, update_quantifiers=update_quantifiers)
253
267
  if self.is_form:
254
268
  # It significantly reduces the "filtering" part of data generation.
255
269
  definition.setdefault("type", "object")
@@ -287,12 +301,14 @@ class OpenAPI20CompositeBody(OpenAPIBody, OpenAPI20Parameter):
287
301
  # We generate an object for formData - it is always required.
288
302
  return bool(self.definition)
289
303
 
290
- def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
304
+ def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
291
305
  """The composite body is transformed into an "object" JSON Schema."""
292
- return parameters_to_json_schema(operation, self.definition)
306
+ return parameters_to_json_schema(operation, self.definition, update_quantifiers=update_quantifiers)
293
307
 
294
308
 
295
- def parameters_to_json_schema(operation: APIOperation, parameters: Iterable[OpenAPIParameter]) -> dict[str, Any]:
309
+ def parameters_to_json_schema(
310
+ operation: APIOperation, parameters: Iterable[OpenAPIParameter], *, update_quantifiers: bool = True
311
+ ) -> dict[str, Any]:
296
312
  """Create an "object" JSON schema from a list of Open API parameters.
297
313
 
298
314
  :param List[OpenAPIParameter] parameters: A list of Open API parameters related to the same location. All of
@@ -332,7 +348,7 @@ def parameters_to_json_schema(operation: APIOperation, parameters: Iterable[Open
332
348
  required = []
333
349
  for parameter in parameters:
334
350
  name = parameter.name
335
- properties[name] = parameter.as_json_schema(operation)
351
+ properties[name] = parameter.as_json_schema(operation, update_quantifiers=update_quantifiers)
336
352
  # If parameter names are duplicated, we need to avoid duplicate entries in `required` anyway
337
353
  if parameter.is_required and name not in required:
338
354
  required.append(name)
@@ -345,7 +361,7 @@ MISSING_SCHEMA_OR_CONTENT_MESSAGE = (
345
361
  )
346
362
 
347
363
  INVALID_SCHEMA_MESSAGE = (
348
- 'Can not generate data for {location} parameter "{name}"! ' "Its schema should be an object, got {schema}"
364
+ 'Can not generate data for {location} parameter "{name}"! Its schema should be an object, got {schema}'
349
365
  )
350
366
 
351
367
 
@@ -354,7 +370,7 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
354
370
  # In Open API 3.0, there could be "schema" or "content" field. They are mutually exclusive.
355
371
  if "schema" in data:
356
372
  if not isinstance(data["schema"], dict):
357
- raise OperationSchemaError(
373
+ raise InvalidSchema(
358
374
  INVALID_SCHEMA_MESSAGE.format(
359
375
  location=data.get("in", ""), name=data.get("name", "<UNKNOWN>"), schema=data["schema"]
360
376
  ),
@@ -368,7 +384,7 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
368
384
  try:
369
385
  content = data["content"]
370
386
  except KeyError as exc:
371
- raise OperationSchemaError(
387
+ raise InvalidSchema(
372
388
  MISSING_SCHEMA_OR_CONTENT_MESSAGE.format(location=data.get("in", ""), name=data.get("name", "<UNKNOWN>")),
373
389
  path=operation.path,
374
390
  method=operation.method,
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from functools import lru_cache
5
+
6
+ try: # pragma: no cover
7
+ import re._constants as sre
8
+ import re._parser as sre_parse
9
+ except ImportError:
10
+ import sre_constants as sre
11
+ import sre_parse
12
+
13
+ ANCHOR = sre.AT
14
+ REPEATS: tuple
15
+ if hasattr(sre, "POSSESSIVE_REPEAT"):
16
+ REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT, sre.POSSESSIVE_REPEAT)
17
+ else:
18
+ REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT)
19
+ LITERAL = sre.LITERAL
20
+ IN = sre.IN
21
+ MAXREPEAT = sre_parse.MAXREPEAT
22
+
23
+
24
+ @lru_cache
25
+ def update_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
26
+ """Update the quantifier of a regular expression based on given min and max lengths."""
27
+ if not pattern or (min_length in (None, 0) and max_length is None):
28
+ return pattern
29
+
30
+ try:
31
+ parsed = sre_parse.parse(pattern)
32
+ return _handle_parsed_pattern(parsed, pattern, min_length, max_length)
33
+ except re.error:
34
+ # Invalid pattern
35
+ return pattern
36
+
37
+
38
+ def _handle_parsed_pattern(parsed: list, pattern: str, min_length: int | None, max_length: int | None) -> str:
39
+ """Handle the parsed pattern and update quantifiers based on different cases."""
40
+ if len(parsed) == 1:
41
+ op, value = parsed[0]
42
+ return _update_quantifier(op, value, pattern, min_length, max_length)
43
+ elif len(parsed) == 2:
44
+ if parsed[0][0] == ANCHOR:
45
+ # Starts with an anchor
46
+ op, value = parsed[1]
47
+ anchor_length = _get_anchor_length(parsed[0][1])
48
+ leading_anchor = pattern[:anchor_length]
49
+ return leading_anchor + _update_quantifier(op, value, pattern[anchor_length:], min_length, max_length)
50
+ if parsed[1][0] == ANCHOR:
51
+ # Ends with an anchor
52
+ op, value = parsed[0]
53
+ anchor_length = _get_anchor_length(parsed[1][1])
54
+ trailing_anchor = pattern[-anchor_length:]
55
+ return _update_quantifier(op, value, pattern[:-anchor_length], min_length, max_length) + trailing_anchor
56
+ elif len(parsed) == 3 and parsed[0][0] == ANCHOR and parsed[2][0] == ANCHOR:
57
+ op, value = parsed[1]
58
+ leading_anchor_length = _get_anchor_length(parsed[0][1])
59
+ trailing_anchor_length = _get_anchor_length(parsed[2][1])
60
+ leading_anchor = pattern[:leading_anchor_length]
61
+ trailing_anchor = pattern[-trailing_anchor_length:]
62
+ return (
63
+ leading_anchor
64
+ + _update_quantifier(
65
+ op, value, pattern[leading_anchor_length:-trailing_anchor_length], min_length, max_length
66
+ )
67
+ + trailing_anchor
68
+ )
69
+ return pattern
70
+
71
+
72
+ def _get_anchor_length(node_type: int) -> int:
73
+ """Determine the length of the anchor based on its type."""
74
+ if node_type in {sre.AT_BEGINNING_STRING, sre.AT_END_STRING, sre.AT_BOUNDARY, sre.AT_NON_BOUNDARY}:
75
+ return 2 # \A, \Z, \b, or \B
76
+ return 1 # ^ or $ or their multiline/locale/unicode variants
77
+
78
+
79
+ def _update_quantifier(op: int, value: tuple, pattern: str, min_length: int | None, max_length: int | None) -> str:
80
+ """Update the quantifier based on the operation type and given constraints."""
81
+ if op in REPEATS:
82
+ return _handle_repeat_quantifier(value, pattern, min_length, max_length)
83
+ if op in (LITERAL, IN) and max_length != 0:
84
+ return _handle_literal_or_in_quantifier(pattern, min_length, max_length)
85
+ return pattern
86
+
87
+
88
+ def _handle_repeat_quantifier(
89
+ value: tuple[int, int, tuple], pattern: str, min_length: int | None, max_length: int | None
90
+ ) -> str:
91
+ """Handle repeat quantifiers (e.g., '+', '*', '?')."""
92
+ min_repeat, max_repeat, _ = value
93
+ min_length, max_length = _build_size(min_repeat, max_repeat, min_length, max_length)
94
+ if min_length > max_length:
95
+ return pattern
96
+ return f"({_strip_quantifier(pattern)})" + _build_quantifier(min_length, max_length)
97
+
98
+
99
+ def _handle_literal_or_in_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
100
+ """Handle literal or character class quantifiers."""
101
+ min_length = 1 if min_length is None else max(min_length, 1)
102
+ return f"({pattern})" + _build_quantifier(min_length, max_length)
103
+
104
+
105
+ def _build_quantifier(minimum: int | None, maximum: int | None) -> str:
106
+ """Construct a quantifier string based on min and max values."""
107
+ if maximum == MAXREPEAT or maximum is None:
108
+ return f"{{{minimum or 0},}}"
109
+ if minimum == maximum:
110
+ return f"{{{minimum}}}"
111
+ return f"{{{minimum or 0},{maximum}}}"
112
+
113
+
114
+ def _build_size(min_repeat: int, max_repeat: int, min_length: int | None, max_length: int | None) -> tuple[int, int]:
115
+ """Merge the current repetition constraints with the provided min and max lengths."""
116
+ if min_length is not None:
117
+ min_repeat = max(min_repeat, min_length)
118
+ if max_length is not None:
119
+ if max_repeat == MAXREPEAT:
120
+ max_repeat = max_length
121
+ else:
122
+ max_repeat = min(max_repeat, max_length)
123
+ return min_repeat, max_repeat
124
+
125
+
126
+ def _strip_quantifier(pattern: str) -> str:
127
+ """Remove quantifier from the pattern."""
128
+ # Lazy & posessive quantifiers
129
+ if pattern.endswith(("*?", "+?", "??", "*+", "?+", "++")):
130
+ return pattern[:-2]
131
+ if pattern.endswith(("?", "*", "+")):
132
+ pattern = pattern[:-1]
133
+ if pattern.endswith("}") and "{" in pattern:
134
+ # Find the start of the exact quantifier and drop everything since that index
135
+ idx = pattern.rfind("{")
136
+ pattern = pattern[:idx]
137
+ return pattern
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
3
+ import sys
4
4
  from functools import lru_cache
5
5
  from typing import Any, Callable, Dict, Union, overload
6
6
  from urllib.request import urlopen
@@ -8,9 +8,11 @@ from urllib.request import urlopen
8
8
  import jsonschema
9
9
  import requests
10
10
 
11
- from ...constants import DEFAULT_RESPONSE_TIMEOUT
12
- from ...internal.copy import fast_deepcopy
13
- from ...loaders import load_yaml
11
+ from schemathesis.core.compat import RefResolutionError
12
+ from schemathesis.core.deserialization import deserialize_yaml
13
+ from schemathesis.core.transforms import deepclone
14
+ from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
15
+
14
16
  from .constants import ALL_KEYWORDS
15
17
  from .converter import to_json_schema_recursive
16
18
  from .utils import get_type
@@ -22,7 +24,7 @@ RECURSION_DEPTH_LIMIT = 100
22
24
  def load_file_impl(location: str, opener: Callable) -> dict[str, Any]:
23
25
  """Load a schema from the given file."""
24
26
  with opener(location) as fd:
25
- return load_yaml(fd)
27
+ return deserialize_yaml(fd)
26
28
 
27
29
 
28
30
  @lru_cache
@@ -39,8 +41,8 @@ def load_file_uri(location: str) -> dict[str, Any]:
39
41
 
40
42
  def load_remote_uri(uri: str) -> Any:
41
43
  """Load the resource and parse it as YAML / JSON."""
42
- response = requests.get(uri, timeout=DEFAULT_RESPONSE_TIMEOUT / 1000)
43
- return load_yaml(response.content)
44
+ response = requests.get(uri, timeout=DEFAULT_RESPONSE_TIMEOUT)
45
+ return deserialize_yaml(response.content)
44
46
 
45
47
 
46
48
  JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
@@ -55,31 +57,57 @@ class InliningResolver(jsonschema.RefResolver):
55
57
  )
56
58
  super().__init__(*args, **kwargs)
57
59
 
60
+ if sys.version_info >= (3, 11):
61
+
62
+ def resolve(self, ref: str) -> tuple[str, Any]:
63
+ try:
64
+ return super().resolve(ref)
65
+ except RefResolutionError as exc:
66
+ exc.add_note(ref)
67
+ raise
68
+ else:
69
+
70
+ def resolve(self, ref: str) -> tuple[str, Any]:
71
+ try:
72
+ return super().resolve(ref)
73
+ except RefResolutionError as exc:
74
+ exc.__notes__ = [ref]
75
+ raise
76
+
58
77
  @overload
59
- def resolve_all(self, item: dict[str, Any], recursion_level: int = 0) -> dict[str, Any]:
60
- pass
78
+ def resolve_all(self, item: dict[str, Any], recursion_level: int = 0) -> dict[str, Any]: ...
61
79
 
62
80
  @overload
63
- def resolve_all(self, item: list, recursion_level: int = 0) -> list:
64
- pass
81
+ def resolve_all(self, item: list, recursion_level: int = 0) -> list: ...
65
82
 
66
83
  def resolve_all(self, item: JSONType, recursion_level: int = 0) -> JSONType:
67
84
  """Recursively resolve all references in the given object."""
85
+ resolve = self.resolve_all
68
86
  if isinstance(item, dict):
69
87
  ref = item.get("$ref")
70
- if ref is not None and isinstance(ref, str):
71
- with self.resolving(ref) as resolved:
88
+ if isinstance(ref, str):
89
+ url, resolved = self.resolve(ref)
90
+ self.push_scope(url)
91
+ try:
72
92
  # If the next level of recursion exceeds the limit, then we need to copy it explicitly
73
93
  # In other cases, this method create new objects for mutable types (dict & list)
74
94
  next_recursion_level = recursion_level + 1
75
95
  if next_recursion_level > RECURSION_DEPTH_LIMIT:
76
- copied = fast_deepcopy(resolved)
96
+ copied = deepclone(resolved)
77
97
  remove_optional_references(copied)
78
98
  return copied
79
- return self.resolve_all(resolved, next_recursion_level)
80
- return {key: self.resolve_all(sub_item, recursion_level) for key, sub_item in item.items()}
99
+ return resolve(resolved, next_recursion_level)
100
+ finally:
101
+ self.pop_scope()
102
+ return {
103
+ key: resolve(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
104
+ for key, sub_item in item.items()
105
+ }
81
106
  if isinstance(item, list):
82
- return [self.resolve_all(sub_item, recursion_level) for sub_item in item]
107
+ return [
108
+ self.resolve_all(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
109
+ for sub_item in item
110
+ ]
83
111
  return item
84
112
 
85
113
  def resolve_in_scope(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any]]:
@@ -89,7 +117,7 @@ class InliningResolver(jsonschema.RefResolver):
89
117
  if "$ref" in definition:
90
118
  self.push_scope(scope)
91
119
  try:
92
- new_scope, definition = fast_deepcopy(self.resolve(definition["$ref"]))
120
+ new_scope, definition = self.resolve(definition["$ref"])
93
121
  finally:
94
122
  self.pop_scope()
95
123
  scopes.append(new_scope)
@@ -186,7 +214,7 @@ def remove_optional_references(schema: dict[str, Any]) -> None:
186
214
  v = s.get(keyword)
187
215
  if v is not None:
188
216
  elided = [sub for sub in v if not can_elide(sub)]
189
- if len(elided) == 1 and "$ref" in elided[0]:
217
+ if len(elided) == 1 and contains_ref(elided[0]):
190
218
  found.append(keyword)
191
219
  return found
192
220
 
@@ -205,41 +233,3 @@ def remove_optional_references(schema: dict[str, Any]) -> None:
205
233
  clean_additional_properties(definition)
206
234
  for k in on_single_item_combinators(definition):
207
235
  del definition[k]
208
-
209
-
210
- @dataclass
211
- class Unresolvable:
212
- pass
213
-
214
-
215
- UNRESOLVABLE = Unresolvable()
216
-
217
-
218
- def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | float | None | Unresolvable:
219
- """Implementation is adapted from Rust's `serde-json` crate.
220
-
221
- Ref: https://github.com/serde-rs/json/blob/master/src/value/mod.rs#L751
222
- """
223
- if not pointer:
224
- return document
225
- if not pointer.startswith("/"):
226
- return UNRESOLVABLE
227
-
228
- def replace(value: str) -> str:
229
- return value.replace("~1", "/").replace("~0", "~")
230
-
231
- tokens = map(replace, pointer.split("/")[1:])
232
- target = document
233
- for token in tokens:
234
- if isinstance(target, dict):
235
- target = target.get(token, UNRESOLVABLE)
236
- if target is UNRESOLVABLE:
237
- return UNRESOLVABLE
238
- elif isinstance(target, list):
239
- try:
240
- target = target[int(token)]
241
- except IndexError:
242
- return UNRESOLVABLE
243
- else:
244
- return UNRESOLVABLE
245
- return target