schemathesis 4.1.4__py3-none-any.whl → 4.2.0__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 (70) hide show
  1. schemathesis/cli/commands/run/executor.py +1 -1
  2. schemathesis/cli/commands/run/handlers/base.py +28 -1
  3. schemathesis/cli/commands/run/handlers/cassettes.py +10 -12
  4. schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
  5. schemathesis/cli/commands/run/handlers/output.py +7 -1
  6. schemathesis/cli/ext/fs.py +1 -1
  7. schemathesis/config/_diff_base.py +3 -1
  8. schemathesis/config/_operations.py +2 -0
  9. schemathesis/config/_phases.py +21 -4
  10. schemathesis/config/_projects.py +10 -2
  11. schemathesis/core/adapter.py +34 -0
  12. schemathesis/core/errors.py +29 -5
  13. schemathesis/core/jsonschema/__init__.py +13 -0
  14. schemathesis/core/jsonschema/bundler.py +163 -0
  15. schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
  16. schemathesis/core/jsonschema/references.py +122 -0
  17. schemathesis/core/jsonschema/types.py +41 -0
  18. schemathesis/core/media_types.py +6 -4
  19. schemathesis/core/parameters.py +37 -0
  20. schemathesis/core/transforms.py +25 -2
  21. schemathesis/core/validation.py +19 -0
  22. schemathesis/engine/context.py +1 -1
  23. schemathesis/engine/errors.py +11 -18
  24. schemathesis/engine/phases/stateful/_executor.py +1 -1
  25. schemathesis/engine/phases/unit/_executor.py +30 -13
  26. schemathesis/errors.py +4 -0
  27. schemathesis/filters.py +2 -2
  28. schemathesis/generation/coverage.py +87 -11
  29. schemathesis/generation/hypothesis/__init__.py +4 -1
  30. schemathesis/generation/hypothesis/builder.py +108 -70
  31. schemathesis/generation/meta.py +5 -14
  32. schemathesis/generation/overrides.py +17 -17
  33. schemathesis/pytest/lazy.py +1 -1
  34. schemathesis/pytest/plugin.py +1 -6
  35. schemathesis/schemas.py +22 -72
  36. schemathesis/specs/graphql/schemas.py +27 -16
  37. schemathesis/specs/openapi/_hypothesis.py +83 -68
  38. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  39. schemathesis/specs/openapi/adapter/parameters.py +504 -0
  40. schemathesis/specs/openapi/adapter/protocol.py +57 -0
  41. schemathesis/specs/openapi/adapter/references.py +19 -0
  42. schemathesis/specs/openapi/adapter/responses.py +329 -0
  43. schemathesis/specs/openapi/adapter/security.py +141 -0
  44. schemathesis/specs/openapi/adapter/v2.py +28 -0
  45. schemathesis/specs/openapi/adapter/v3_0.py +28 -0
  46. schemathesis/specs/openapi/adapter/v3_1.py +28 -0
  47. schemathesis/specs/openapi/checks.py +99 -90
  48. schemathesis/specs/openapi/converter.py +114 -27
  49. schemathesis/specs/openapi/examples.py +210 -168
  50. schemathesis/specs/openapi/negative/__init__.py +12 -7
  51. schemathesis/specs/openapi/negative/mutations.py +68 -40
  52. schemathesis/specs/openapi/references.py +2 -175
  53. schemathesis/specs/openapi/schemas.py +142 -490
  54. schemathesis/specs/openapi/serialization.py +15 -7
  55. schemathesis/specs/openapi/stateful/__init__.py +17 -12
  56. schemathesis/specs/openapi/stateful/inference.py +13 -11
  57. schemathesis/specs/openapi/stateful/links.py +5 -20
  58. schemathesis/specs/openapi/types/__init__.py +3 -0
  59. schemathesis/specs/openapi/types/v3.py +68 -0
  60. schemathesis/specs/openapi/utils.py +1 -13
  61. schemathesis/transport/requests.py +3 -11
  62. schemathesis/transport/serialization.py +63 -27
  63. schemathesis/transport/wsgi.py +1 -8
  64. {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/METADATA +2 -2
  65. {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/RECORD +68 -53
  66. schemathesis/specs/openapi/parameters.py +0 -405
  67. schemathesis/specs/openapi/security.py +0 -162
  68. {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/WHEEL +0 -0
  69. {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/entry_points.txt +0 -0
  70. {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -29,7 +29,8 @@ from hypothesis_jsonschema._canonicalise import canonicalish
29
29
  from hypothesis_jsonschema._from_schema import STRING_FORMATS as BUILT_IN_STRING_FORMATS
30
30
 
31
31
  from schemathesis.core import INTERNAL_BUFFER_SIZE, NOT_SET
32
- from schemathesis.core.compat import RefResolutionError
32
+ from schemathesis.core.compat import RefResolutionError, RefResolver
33
+ from schemathesis.core.parameters import ParameterLocation
33
34
  from schemathesis.core.transforms import deepclone
34
35
  from schemathesis.core.validation import contains_unicode_surrogate_pair, has_invalid_characters, is_latin_1_encodable
35
36
  from schemathesis.generation import GenerationMode
@@ -121,31 +122,62 @@ def cached_draw(strategy: st.SearchStrategy) -> Any:
121
122
 
122
123
  @dataclass
123
124
  class CoverageContext:
125
+ root_schema: dict[str, Any]
124
126
  generation_modes: list[GenerationMode]
125
- location: str
127
+ location: ParameterLocation
128
+ media_type: tuple[str, str] | None
126
129
  is_required: bool
127
130
  path: list[str | int]
128
131
  custom_formats: dict[str, st.SearchStrategy]
129
132
  validator_cls: type[jsonschema.protocols.Validator]
130
-
131
- __slots__ = ("location", "generation_modes", "is_required", "path", "custom_formats", "validator_cls")
133
+ _resolver: RefResolver | None
134
+
135
+ __slots__ = (
136
+ "root_schema",
137
+ "location",
138
+ "media_type",
139
+ "generation_modes",
140
+ "is_required",
141
+ "path",
142
+ "custom_formats",
143
+ "validator_cls",
144
+ "_resolver",
145
+ )
132
146
 
133
147
  def __init__(
134
148
  self,
135
149
  *,
136
- location: str,
150
+ root_schema: dict[str, Any],
151
+ location: ParameterLocation,
152
+ media_type: tuple[str, str] | None,
137
153
  generation_modes: list[GenerationMode] | None = None,
138
154
  is_required: bool,
139
155
  path: list[str | int] | None = None,
140
156
  custom_formats: dict[str, st.SearchStrategy],
141
157
  validator_cls: type[jsonschema.protocols.Validator],
158
+ _resolver: RefResolver | None = None,
142
159
  ) -> None:
160
+ self.root_schema = root_schema
143
161
  self.location = location
162
+ self.media_type = media_type
144
163
  self.generation_modes = generation_modes if generation_modes is not None else list(GenerationMode)
145
164
  self.is_required = is_required
146
165
  self.path = path or []
147
166
  self.custom_formats = custom_formats
148
167
  self.validator_cls = validator_cls
168
+ self._resolver = _resolver
169
+
170
+ @property
171
+ def resolver(self) -> RefResolver:
172
+ """Lazy-initialized cached resolver."""
173
+ if self._resolver is None:
174
+ self._resolver = RefResolver.from_schema(self.root_schema)
175
+ return cast(RefResolver, self._resolver)
176
+
177
+ def resolve_ref(self, ref: str) -> dict | bool:
178
+ """Resolve a $ref to its schema definition."""
179
+ _, resolved = self.resolver.resolve(ref)
180
+ return resolved
149
181
 
150
182
  @contextmanager
151
183
  def at(self, key: str | int) -> Generator[None, None, None]:
@@ -161,22 +193,28 @@ class CoverageContext:
161
193
 
162
194
  def with_positive(self) -> CoverageContext:
163
195
  return CoverageContext(
196
+ root_schema=self.root_schema,
164
197
  location=self.location,
198
+ media_type=self.media_type,
165
199
  generation_modes=[GenerationMode.POSITIVE],
166
200
  is_required=self.is_required,
167
201
  path=self.path,
168
202
  custom_formats=self.custom_formats,
169
203
  validator_cls=self.validator_cls,
204
+ _resolver=self._resolver,
170
205
  )
171
206
 
172
207
  def with_negative(self) -> CoverageContext:
173
208
  return CoverageContext(
209
+ root_schema=self.root_schema,
174
210
  location=self.location,
211
+ media_type=self.media_type,
175
212
  generation_modes=[GenerationMode.NEGATIVE],
176
213
  is_required=self.is_required,
177
214
  path=self.path,
178
215
  custom_formats=self.custom_formats,
179
216
  validator_cls=self.validator_cls,
217
+ _resolver=self._resolver,
180
218
  )
181
219
 
182
220
  def is_valid_for_location(self, value: Any) -> bool:
@@ -195,7 +233,16 @@ class CoverageContext:
195
233
  return True
196
234
 
197
235
  def will_be_serialized_to_string(self) -> bool:
198
- return self.location in ("query", "path", "header", "cookie")
236
+ return self.location in ("query", "path", "header", "cookie") or (
237
+ self.location == "body"
238
+ and self.media_type
239
+ in frozenset(
240
+ [
241
+ ("multipart", "form-data"),
242
+ ("application", "x-www-form-urlencoded"),
243
+ ]
244
+ )
245
+ )
199
246
 
200
247
  def can_be_negated(self, schema: dict[str, Any]) -> bool:
201
248
  # Path, query, header, and cookie parameters will be stringified anyway
@@ -213,6 +260,10 @@ class CoverageContext:
213
260
  return cached_draw(strategy)
214
261
 
215
262
  def generate_from_schema(self, schema: dict | bool) -> Any:
263
+ if isinstance(schema, dict) and "$ref" in schema:
264
+ reference = schema["$ref"]
265
+ # Deep clone to avoid circular references in Python objects
266
+ schema = deepclone(self.resolve_ref(reference))
216
267
  if isinstance(schema, bool):
217
268
  return 0
218
269
  keys = sorted([k for k in schema if not k.startswith("x-") and k not in ["description", "example", "examples"]])
@@ -284,10 +335,17 @@ class CoverageContext:
284
335
  )
285
336
 
286
337
  if keys == ["allOf"]:
338
+ for idx, sub_schema in enumerate(schema["allOf"]):
339
+ if "$ref" in sub_schema:
340
+ schema["allOf"][idx] = self.resolve_ref(sub_schema["$ref"])
341
+
287
342
  schema = canonicalish(schema)
288
343
  if isinstance(schema, dict) and "allOf" not in schema:
289
344
  return self.generate_from_schema(schema)
290
345
 
346
+ if isinstance(schema, dict) and "examples" in schema:
347
+ # Examples may contain binary data which will fail the canonicalisation process in `hypothesis-jsonschema`
348
+ schema = {key: value for key, value in schema.items() if key != "examples"}
291
349
  return self.generate_from(from_schema(schema, custom_formats=self.custom_formats))
292
350
 
293
351
 
@@ -306,7 +364,7 @@ else:
306
364
 
307
365
 
308
366
  def _encode(o: Any) -> str:
309
- return "".join(_iterencode(o, 0))
367
+ return "".join(_iterencode(o, False))
310
368
 
311
369
 
312
370
  def _to_hashable_key(value: T, _encode: Callable = _encode) -> tuple[type, str | T]:
@@ -360,6 +418,9 @@ def _cover_positive_for_type(
360
418
  yield from cover_schema_iter(ctx, all_of[0])
361
419
  else:
362
420
  with suppress(jsonschema.SchemaError):
421
+ for idx, sub_schema in enumerate(all_of):
422
+ if "$ref" in sub_schema:
423
+ all_of[idx] = ctx.resolve_ref(sub_schema["$ref"])
363
424
  canonical = canonicalish(schema)
364
425
  yield from cover_schema_iter(ctx, canonical)
365
426
  if enum is not NOT_SET:
@@ -414,6 +475,21 @@ def cover_schema_iter(
414
475
  ) -> Generator[GeneratedValue, None, None]:
415
476
  if seen is None:
416
477
  seen = HashSet()
478
+
479
+ if isinstance(schema, dict) and "$ref" in schema:
480
+ reference = schema["$ref"]
481
+ try:
482
+ resolved = ctx.resolve_ref(reference)
483
+ if isinstance(resolved, dict):
484
+ schema = {**resolved, **{k: v for k, v in schema.items() if k != "$ref"}}
485
+ yield from cover_schema_iter(ctx, schema, seen)
486
+ else:
487
+ yield from cover_schema_iter(ctx, resolved, seen)
488
+ return
489
+ except RefResolutionError:
490
+ # Can't resolve a reference - at this point, we can't generate anything useful as `$ref` is in the current schema root
491
+ return
492
+
417
493
  if schema == {} or schema is True:
418
494
  types = ["null", "boolean", "string", "number", "array", "object"]
419
495
  schema = {}
@@ -881,7 +957,7 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
881
957
  smaller = largest - multiple_of
882
958
  else:
883
959
  smaller = maximum - 1
884
- if (smaller > 0 and (minimum is None or smaller >= minimum)) and seen.insert(smaller):
960
+ if (minimum is None or smaller >= minimum) and seen.insert(smaller):
885
961
  yield PositiveValue(smaller, description="Near-boundary number")
886
962
 
887
963
 
@@ -1232,7 +1308,7 @@ def _negative_type(
1232
1308
  del strategies["integer"]
1233
1309
  if "integer" in types:
1234
1310
  strategies["number"] = FLOAT_STRATEGY.filter(_is_non_integer_float)
1235
- if ctx.location == "query":
1311
+ if ctx.location == ParameterLocation.QUERY:
1236
1312
  strategies.pop("object", None)
1237
1313
  if filter_func is not None:
1238
1314
  for ty, strategy in strategies.items():
@@ -1264,10 +1340,10 @@ def _negative_type(
1264
1340
  def _does_not_match_the_original_schema(value: Any) -> bool:
1265
1341
  return not is_valid(str(value))
1266
1342
 
1267
- if ctx.location == "path":
1343
+ if ctx.location == ParameterLocation.PATH:
1268
1344
  for ty, strategy in strategies.items():
1269
1345
  strategies[ty] = strategy.map(jsonify).map(quote_path_parameter)
1270
- elif ctx.location == "query":
1346
+ elif ctx.location == ParameterLocation.QUERY:
1271
1347
  for ty, strategy in strategies.items():
1272
1348
  strategies[ty] = strategy.map(jsonify)
1273
1349
 
@@ -8,9 +8,10 @@ def setup() -> None:
8
8
  from hypothesis.internal.reflection import is_first_param_referenced_in_function
9
9
  from hypothesis.strategies._internal import collections, core
10
10
  from hypothesis.vendor import pretty
11
- from hypothesis_jsonschema import _from_schema, _resolve
11
+ from hypothesis_jsonschema import _canonicalise, _from_schema, _resolve
12
12
 
13
13
  from schemathesis.core import INTERNAL_BUFFER_SIZE
14
+ from schemathesis.core.jsonschema.types import _get_type
14
15
  from schemathesis.core.transforms import deepclone
15
16
 
16
17
  # Forcefully initializes Hypothesis' global PRNG to avoid races that initialize it
@@ -37,6 +38,8 @@ def setup() -> None:
37
38
  root_core.RepresentationPrinter = RepresentationPrinter # type: ignore
38
39
  _resolve.deepcopy = deepclone # type: ignore
39
40
  _from_schema.deepcopy = deepclone # type: ignore
41
+ _from_schema.get_type = _get_type # type: ignore
42
+ _canonicalise.get_type = _get_type # type: ignore
40
43
  root_core.BUFFER_SIZE = INTERNAL_BUFFER_SIZE # type: ignore
41
44
  engine.BUFFER_SIZE = INTERNAL_BUFFER_SIZE
42
45
  collections.BUFFER_SIZE = INTERNAL_BUFFER_SIZE # type: ignore