schemathesis 4.1.4__py3-none-any.whl → 4.2.1__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 +109 -137
  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 +79 -2
  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.1.dist-info}/METADATA +2 -2
  65. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.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.1.dist-info}/WHEEL +0 -0
  69. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/entry_points.txt +0 -0
  70. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -14,9 +14,10 @@ from typing import TYPE_CHECKING, Callable, Iterator, Sequence, cast
14
14
 
15
15
  from schemathesis import errors
16
16
  from schemathesis.core.errors import (
17
- RECURSIVE_REFERENCE_ERROR_MESSAGE,
17
+ InfiniteRecursiveReference,
18
18
  InvalidTransition,
19
19
  SerializationNotPossible,
20
+ UnresolvableReference,
20
21
  format_exception,
21
22
  get_request_error_extras,
22
23
  get_request_error_message,
@@ -28,7 +29,7 @@ if TYPE_CHECKING:
28
29
  import requests
29
30
  from requests.exceptions import ChunkedEncodingError
30
31
 
31
- __all__ = ["EngineErrorInfo", "DeadlineExceeded", "UnsupportedRecursiveReference", "UnexpectedError"]
32
+ __all__ = ["EngineErrorInfo", "DeadlineExceeded", "UnexpectedError"]
32
33
 
33
34
 
34
35
  class DeadlineExceeded(errors.SchemathesisError):
@@ -43,13 +44,6 @@ class DeadlineExceeded(errors.SchemathesisError):
43
44
  )
44
45
 
45
46
 
46
- class UnsupportedRecursiveReference(errors.SchemathesisError):
47
- """Recursive reference is impossible to resolve due to current limitations."""
48
-
49
- def __init__(self) -> None:
50
- super().__init__(RECURSIVE_REFERENCE_ERROR_MESSAGE)
51
-
52
-
53
47
  class UnexpectedError(errors.SchemathesisError):
54
48
  """An unexpected error during the engine execution.
55
49
 
@@ -103,7 +97,6 @@ class EngineErrorInfo:
103
97
  return "Schema Error"
104
98
 
105
99
  return {
106
- RuntimeErrorKind.SCHEMA_UNSUPPORTED: "Unsupported Schema",
107
100
  RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND: "Missing Open API links",
108
101
  RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE: "Invalid OpenAPI Links Definition",
109
102
  RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR: "Unknown GraphQL Scalar",
@@ -120,9 +113,6 @@ class EngineErrorInfo:
120
113
  if isinstance(self._error, requests.RequestException):
121
114
  return get_request_error_message(self._error)
122
115
 
123
- if self._kind == RuntimeErrorKind.SCHEMA_UNSUPPORTED:
124
- return str(self._error).strip()
125
-
126
116
  if self._kind == RuntimeErrorKind.HYPOTHESIS_UNSUPPORTED_GRAPHQL_SCALAR and isinstance(
127
117
  self._error, hypothesis.errors.InvalidArgument
128
118
  ):
@@ -175,7 +165,8 @@ class EngineErrorInfo:
175
165
  return self._kind not in (
176
166
  RuntimeErrorKind.SCHEMA_INVALID_REGULAR_EXPRESSION,
177
167
  RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE,
178
- RuntimeErrorKind.SCHEMA_UNSUPPORTED,
168
+ RuntimeErrorKind.SCHEMA_INVALID_UNRESOLVABLE_REFERENCE,
169
+ RuntimeErrorKind.SCHEMA_INVALID_INFINITE_RECURSION,
179
170
  RuntimeErrorKind.SCHEMA_GENERIC,
180
171
  RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND,
181
172
  RuntimeErrorKind.SERIALIZATION_NOT_POSSIBLE,
@@ -312,8 +303,9 @@ class RuntimeErrorKind(str, enum.Enum):
312
303
 
313
304
  SCHEMA_INVALID_REGULAR_EXPRESSION = "schema_invalid_regular_expression"
314
305
  SCHEMA_INVALID_STATE_MACHINE = "schema_invalid_state_machine"
306
+ SCHEMA_INVALID_INFINITE_RECURSION = "schema_invalid_infinite_recursion"
307
+ SCHEMA_INVALID_UNRESOLVABLE_REFERENCE = "schema_invalid_unresolvable_reference"
315
308
  SCHEMA_NO_LINKS_FOUND = "schema_no_links_found"
316
- SCHEMA_UNSUPPORTED = "schema_unsupported"
317
309
  SCHEMA_GENERIC = "schema_generic"
318
310
 
319
311
  SERIALIZATION_NOT_POSSIBLE = "serialization_not_possible"
@@ -368,9 +360,10 @@ def _classify(*, error: Exception) -> RuntimeErrorKind:
368
360
  return RuntimeErrorKind.SCHEMA_INVALID_STATE_MACHINE
369
361
  if isinstance(error, errors.NoLinksFound):
370
362
  return RuntimeErrorKind.SCHEMA_NO_LINKS_FOUND
371
- if isinstance(error, UnsupportedRecursiveReference):
372
- # Recursive references are not supported right now
373
- return RuntimeErrorKind.SCHEMA_UNSUPPORTED
363
+ if isinstance(error, InfiniteRecursiveReference):
364
+ return RuntimeErrorKind.SCHEMA_INVALID_INFINITE_RECURSION
365
+ if isinstance(error, UnresolvableReference):
366
+ return RuntimeErrorKind.SCHEMA_INVALID_UNRESOLVABLE_REFERENCE
374
367
  if isinstance(error, errors.SerializationError):
375
368
  if isinstance(error, errors.UnboundPrefix):
376
369
  return RuntimeErrorKind.SERIALIZATION_UNBOUNDED_PREFIX
@@ -46,7 +46,7 @@ from schemathesis.generation.metrics import MetricCollector
46
46
  def _get_hypothesis_settings_kwargs_override(settings: hypothesis.settings) -> dict[str, Any]:
47
47
  """Get the settings that should be overridden to match the defaults for API state machines."""
48
48
  kwargs = {}
49
- hypothesis_default = hypothesis.settings()
49
+ hypothesis_default = hypothesis.settings.get_profile("default")
50
50
  if settings.phases == hypothesis_default.phases:
51
51
  kwargs["phases"] = DEFAULT_STATE_MACHINE_SETTINGS.phases
52
52
  if settings.stateful_step_count == hypothesis_default.stateful_step_count:
@@ -8,7 +8,6 @@ from warnings import WarningMessage, catch_warnings
8
8
 
9
9
  import requests
10
10
  from hypothesis.errors import InvalidArgument
11
- from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
12
11
  from jsonschema.exceptions import SchemaError as JsonSchemaError
13
12
  from jsonschema.exceptions import ValidationError
14
13
  from requests.exceptions import ChunkedEncodingError
@@ -37,7 +36,6 @@ from schemathesis.engine.errors import (
37
36
  TestingState,
38
37
  UnexpectedError,
39
38
  UnrecoverableNetworkError,
40
- UnsupportedRecursiveReference,
41
39
  clear_hypothesis_notes,
42
40
  deduplicate_errors,
43
41
  is_unrecoverable_network_error,
@@ -47,10 +45,12 @@ from schemathesis.engine.recorder import ScenarioRecorder
47
45
  from schemathesis.generation import metrics, overrides
48
46
  from schemathesis.generation.case import Case
49
47
  from schemathesis.generation.hypothesis.builder import (
48
+ InfiniteRecursiveReferenceMark,
50
49
  InvalidHeadersExampleMark,
51
50
  InvalidRegexMark,
52
51
  MissingPathParameters,
53
52
  NonSerializableMark,
53
+ UnresolvableReferenceMark,
54
54
  UnsatisfiableExampleMark,
55
55
  )
56
56
  from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
@@ -173,14 +173,24 @@ def run_test(
173
173
  status = Status.ERROR
174
174
  try:
175
175
  operation.schema.validate()
176
- msg = "Unexpected error during testing of this API operation"
177
- exc_msg = str(exc)
178
- if exc_msg:
179
- msg += f": {exc_msg}"
180
- try:
181
- raise InternalError(msg) from exc
182
- except InternalError as exc:
183
- yield non_fatal_error(exc)
176
+ # JSON Schema validation can miss it if there is `$ref` adjacent to `type` on older specifications
177
+ if str(exc).startswith("Unknown type"):
178
+ yield non_fatal_error(
179
+ InvalidSchema(
180
+ message=str(exc),
181
+ path=operation.path,
182
+ method=operation.method,
183
+ )
184
+ )
185
+ else:
186
+ msg = "Unexpected error during testing of this API operation"
187
+ exc_msg = str(exc)
188
+ if exc_msg:
189
+ msg += f": {exc_msg}"
190
+ try:
191
+ raise InternalError(msg) from exc
192
+ except InternalError as exc:
193
+ yield non_fatal_error(exc)
184
194
  except ValidationError as exc:
185
195
  yield non_fatal_error(
186
196
  InvalidSchema.from_jsonschema_error(
@@ -190,9 +200,6 @@ def run_test(
190
200
  config=ctx.config.output,
191
201
  )
192
202
  )
193
- except HypothesisRefResolutionError:
194
- status = Status.ERROR
195
- yield non_fatal_error(UnsupportedRecursiveReference())
196
203
  except InvalidArgument as exc:
197
204
  status = Status.ERROR
198
205
  message = get_invalid_regular_expression_message(warnings)
@@ -265,6 +272,16 @@ def run_test(
265
272
  status = Status.ERROR
266
273
  yield non_fatal_error(missing_path_parameters)
267
274
 
275
+ infinite_recursive_reference = InfiniteRecursiveReferenceMark.get(test_function)
276
+ if infinite_recursive_reference:
277
+ status = Status.ERROR
278
+ yield non_fatal_error(infinite_recursive_reference)
279
+
280
+ unresolvable_reference = UnresolvableReferenceMark.get(test_function)
281
+ if unresolvable_reference:
282
+ status = Status.ERROR
283
+ yield non_fatal_error(unresolvable_reference)
284
+
268
285
  for error in deduplicate_errors(errors):
269
286
  yield non_fatal_error(error)
270
287
 
schemathesis/errors.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from schemathesis.core.errors import (
4
4
  HookError,
5
5
  IncorrectUsage,
6
+ InfiniteRecursiveReference,
6
7
  InternalError,
7
8
  InvalidHeadersExample,
8
9
  InvalidRateLimit,
@@ -19,11 +20,13 @@ from schemathesis.core.errors import (
19
20
  SerializationNotPossible,
20
21
  TransitionValidationError,
21
22
  UnboundPrefix,
23
+ UnresolvableReference,
22
24
  )
23
25
 
24
26
  __all__ = [
25
27
  "HookError",
26
28
  "IncorrectUsage",
29
+ "InfiniteRecursiveReference",
27
30
  "InternalError",
28
31
  "InvalidHeadersExample",
29
32
  "InvalidRateLimit",
@@ -40,4 +43,5 @@ __all__ = [
40
43
  "SerializationNotPossible",
41
44
  "TransitionValidationError",
42
45
  "UnboundPrefix",
46
+ "UnresolvableReference",
43
47
  ]
schemathesis/filters.py CHANGED
@@ -382,13 +382,13 @@ def expression_to_filter_function(expression: str) -> Callable[[HasAPIOperation]
382
382
  if op == "==":
383
383
 
384
384
  def filter_function(ctx: HasAPIOperation) -> bool:
385
- definition = ctx.operation.definition.resolved
385
+ definition = ctx.operation.definition.raw
386
386
  resolved = resolve_pointer(definition, pointer)
387
387
  return resolved == value
388
388
  else:
389
389
 
390
390
  def filter_function(ctx: HasAPIOperation) -> bool:
391
- definition = ctx.operation.definition.resolved
391
+ definition = ctx.operation.definition.raw
392
392
  resolved = resolve_pointer(definition, pointer)
393
393
  return resolved != value
394
394
 
@@ -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
 
@@ -1,4 +1,8 @@
1
- from typing import Any
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from functools import lru_cache
5
+ from typing import Any, Literal
2
6
 
3
7
 
4
8
  def setup() -> None:
@@ -8,9 +12,12 @@ def setup() -> None:
8
12
  from hypothesis.internal.reflection import is_first_param_referenced_in_function
9
13
  from hypothesis.strategies._internal import collections, core
10
14
  from hypothesis.vendor import pretty
11
- from hypothesis_jsonschema import _from_schema, _resolve
15
+ from hypothesis_jsonschema import _canonicalise, _from_schema, _resolve
16
+ from hypothesis_jsonschema._canonicalise import SCHEMA_KEYS, SCHEMA_OBJECT_KEYS, merged
17
+ from hypothesis_jsonschema._resolve import LocalResolver
12
18
 
13
19
  from schemathesis.core import INTERNAL_BUFFER_SIZE
20
+ from schemathesis.core.jsonschema.types import _get_type
14
21
  from schemathesis.core.transforms import deepclone
15
22
 
16
23
  # Forcefully initializes Hypothesis' global PRNG to avoid races that initialize it
@@ -34,9 +41,79 @@ def setup() -> None:
34
41
  # depending on the schema size (~300 seconds -> 4.5 seconds in one of the benchmarks)
35
42
  return None
36
43
 
44
+ class CacheableSchema:
45
+ """Cache schema by its JSON representation.
46
+
47
+ Canonicalisation is not required as schemas with the same JSON representation
48
+ will have the same validator.
49
+ """
50
+
51
+ __slots__ = ("schema", "encoded")
52
+
53
+ def __init__(self, schema: dict[str, Any]) -> None:
54
+ self.schema = schema
55
+ self.encoded = hash(json.dumps(schema, sort_keys=True))
56
+
57
+ def __eq__(self, other: "CacheableSchema") -> bool: # type: ignore
58
+ return self.encoded == other.encoded
59
+
60
+ def __hash__(self) -> int:
61
+ return self.encoded
62
+
63
+ SCHEMA_KEYS = frozenset(SCHEMA_KEYS)
64
+ SCHEMA_OBJECT_KEYS = frozenset(SCHEMA_OBJECT_KEYS)
65
+
66
+ @lru_cache()
67
+ def get_resolver(cache_key: CacheableSchema) -> LocalResolver:
68
+ """LRU resolver cache."""
69
+ return LocalResolver.from_schema(cache_key.schema)
70
+
71
+ def resolve_all_refs(
72
+ schema: Literal[True, False] | dict[str, Any],
73
+ *,
74
+ resolver: LocalResolver | None = None,
75
+ ) -> dict[str, Any]:
76
+ if schema is True:
77
+ return {}
78
+ if schema is False:
79
+ return {"not": {}}
80
+ if not schema:
81
+ return schema
82
+ if resolver is None:
83
+ resolver = get_resolver(CacheableSchema(schema))
84
+
85
+ _resolve_all_refs = resolve_all_refs
86
+
87
+ if "$ref" in schema:
88
+ s = dict(schema)
89
+ ref = s.pop("$ref")
90
+ url, resolved = resolver.resolve(ref)
91
+ resolver.push_scope(url)
92
+ try:
93
+ return merged([s, _resolve_all_refs(deepclone(resolved), resolver=resolver)]) # type: ignore
94
+ finally:
95
+ resolver.pop_scope()
96
+
97
+ for key, value in schema.items():
98
+ if key in SCHEMA_KEYS:
99
+ if isinstance(value, list):
100
+ schema[key] = [_resolve_all_refs(v, resolver=resolver) if isinstance(v, dict) else v for v in value]
101
+ elif isinstance(value, dict):
102
+ schema[key] = _resolve_all_refs(value, resolver=resolver)
103
+ if key in SCHEMA_OBJECT_KEYS:
104
+ schema[key] = {
105
+ k: _resolve_all_refs(v, resolver=resolver) if isinstance(v, dict) else v for k, v in value.items()
106
+ }
107
+ return schema
108
+
37
109
  root_core.RepresentationPrinter = RepresentationPrinter # type: ignore
38
110
  _resolve.deepcopy = deepclone # type: ignore
111
+ _resolve.resolve_all_refs = resolve_all_refs # type: ignore
39
112
  _from_schema.deepcopy = deepclone # type: ignore
113
+ _from_schema.get_type = _get_type # type: ignore
114
+ _from_schema.resolve_all_refs = resolve_all_refs # type: ignore
115
+ _canonicalise.get_type = _get_type # type: ignore
116
+ _canonicalise.CacheableSchema = CacheableSchema # type: ignore
40
117
  root_core.BUFFER_SIZE = INTERNAL_BUFFER_SIZE # type: ignore
41
118
  engine.BUFFER_SIZE = INTERNAL_BUFFER_SIZE
42
119
  collections.BUFFER_SIZE = INTERNAL_BUFFER_SIZE # type: ignore