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
@@ -10,10 +10,13 @@ from typing import Any, Callable, Sequence, TypeVar
10
10
  from hypothesis import reject
11
11
  from hypothesis import strategies as st
12
12
  from hypothesis.strategies._internal.featureflags import FeatureStrategy
13
+ from hypothesis_jsonschema._canonicalise import canonicalish
13
14
 
15
+ from schemathesis.core.jsonschema import get_type
16
+ from schemathesis.core.jsonschema.types import JsonSchemaObject
17
+ from schemathesis.core.parameters import ParameterLocation
14
18
  from schemathesis.core.transforms import deepclone
15
19
 
16
- from ..utils import get_type, is_header_location
17
20
  from .types import Draw, Schema
18
21
  from .utils import can_negate
19
22
 
@@ -70,29 +73,38 @@ class MutationContext:
70
73
  keywords: Schema # only keywords
71
74
  non_keywords: Schema # everything else
72
75
  # Schema location within API operation (header, query, etc)
73
- location: str
76
+ location: ParameterLocation
74
77
  # Payload media type, if available
75
78
  media_type: str | None
76
79
 
77
80
  __slots__ = ("keywords", "non_keywords", "location", "media_type")
78
81
 
79
- @property
80
- def is_header_location(self) -> bool:
81
- return is_header_location(self.location)
82
+ def __init__(
83
+ self,
84
+ *,
85
+ keywords: Schema,
86
+ non_keywords: Schema,
87
+ location: ParameterLocation,
88
+ media_type: str | None,
89
+ ) -> None:
90
+ self.keywords = keywords
91
+ self.non_keywords = non_keywords
92
+ self.location = location
93
+ self.media_type = media_type
82
94
 
83
95
  @property
84
96
  def is_path_location(self) -> bool:
85
- return self.location == "path"
97
+ return self.location == ParameterLocation.PATH
86
98
 
87
99
  @property
88
100
  def is_query_location(self) -> bool:
89
- return self.location == "query"
101
+ return self.location == ParameterLocation.QUERY
90
102
 
91
103
  def mutate(self, draw: Draw) -> Schema:
92
104
  # On the top level, Schemathesis creates "object" schemas for all parameter "in" values except "body", which is
93
105
  # taken as-is. Therefore, we can only apply mutations that won't change the Open API semantics of the schema.
94
106
  mutations: list[Mutation]
95
- if self.location in ("header", "cookie", "query"):
107
+ if self.location in (ParameterLocation.HEADER, ParameterLocation.COOKIE, ParameterLocation.QUERY):
96
108
  # These objects follow this pattern:
97
109
  # {
98
110
  # "properties": properties,
@@ -127,7 +139,7 @@ class MutationContext:
127
139
  # If we failed to apply anything, then reject the whole case
128
140
  reject() # type: ignore
129
141
  new_schema.update(self.non_keywords)
130
- if self.is_header_location:
142
+ if self.location.is_in_header:
131
143
  # All headers should have names that can be sent over network
132
144
  new_schema["propertyNames"] = {"type": "string", "format": "_header_name"}
133
145
  for sub_schema in new_schema.get("properties", {}).values():
@@ -156,10 +168,10 @@ def for_types(*allowed_types: str) -> Callable[[Mutation], Mutation]:
156
168
 
157
169
  def wrapper(mutation: Mutation) -> Mutation:
158
170
  @wraps(mutation)
159
- def inner(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
171
+ def inner(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
160
172
  types = get_type(schema)
161
173
  if _allowed_types & set(types):
162
- return mutation(context, draw, schema)
174
+ return mutation(ctx, draw, schema)
163
175
  return MutationResult.FAILURE
164
176
 
165
177
  return inner
@@ -168,7 +180,7 @@ def for_types(*allowed_types: str) -> Callable[[Mutation], Mutation]:
168
180
 
169
181
 
170
182
  @for_types("object")
171
- def remove_required_property(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
183
+ def remove_required_property(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
172
184
  """Remove a required property.
173
185
 
174
186
  Effect: Some property won't be generated.
@@ -201,29 +213,29 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
201
213
  return MutationResult.SUCCESS
202
214
 
203
215
 
204
- def change_type(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
216
+ def change_type(ctx: MutationContext, draw: Draw, schema: JsonSchemaObject) -> MutationResult:
205
217
  """Change type of values accepted by a schema."""
206
218
  if "type" not in schema:
207
219
  # The absence of this keyword means that the schema values can be of any type;
208
220
  # Therefore, we can't choose a different type
209
221
  return MutationResult.FAILURE
210
- if context.media_type == "application/x-www-form-urlencoded":
222
+ if ctx.media_type == "application/x-www-form-urlencoded":
211
223
  # Form data should be an object, do not change it
212
224
  return MutationResult.FAILURE
213
225
  # For headers, query and path parameters, if the current type is string, then it already
214
226
  # includes all possible values as those parameters will be stringified before sending,
215
227
  # therefore it can't be negated.
216
228
  types = get_type(schema)
217
- if "string" in types and (context.is_header_location or context.is_path_location or context.is_query_location):
229
+ if "string" in types and (ctx.location.is_in_header or ctx.is_path_location or ctx.is_query_location):
218
230
  return MutationResult.FAILURE
219
- candidates = _get_type_candidates(context, schema)
231
+ candidates = _get_type_candidates(ctx, schema)
220
232
  if not candidates:
221
233
  # Schema covers all possible types, not possible to choose something else
222
234
  return MutationResult.FAILURE
223
235
  if len(candidates) == 1:
224
236
  new_type = candidates.pop()
225
237
  schema["type"] = new_type
226
- _ensure_query_serializes_to_non_empty(context, schema)
238
+ _ensure_query_serializes_to_non_empty(ctx, schema)
227
239
  prevent_unsatisfiable_schema(schema, new_type)
228
240
  return MutationResult.SUCCESS
229
241
  # Choose one type that will be present in the final candidates list
@@ -236,21 +248,21 @@ def change_type(context: MutationContext, draw: Draw, schema: Schema) -> Mutatio
236
248
  ]
237
249
  new_type = draw(st.sampled_from(remaining_candidates))
238
250
  schema["type"] = new_type
239
- _ensure_query_serializes_to_non_empty(context, schema)
251
+ _ensure_query_serializes_to_non_empty(ctx, schema)
240
252
  prevent_unsatisfiable_schema(schema, new_type)
241
253
  return MutationResult.SUCCESS
242
254
 
243
255
 
244
- def _ensure_query_serializes_to_non_empty(context: MutationContext, schema: Schema) -> None:
245
- if context.is_query_location and schema.get("type") == "array":
256
+ def _ensure_query_serializes_to_non_empty(ctx: MutationContext, schema: Schema) -> None:
257
+ if ctx.is_query_location and schema.get("type") == "array":
246
258
  # Query parameters with empty arrays or arrays of `None` or empty arrays / objects will not appear in the final URL
247
259
  schema["minItems"] = schema.get("minItems") or 1
248
260
  schema.setdefault("items", {}).update({"not": {"enum": [None, [], {}]}})
249
261
 
250
262
 
251
- def _get_type_candidates(context: MutationContext, schema: Schema) -> set[str]:
263
+ def _get_type_candidates(ctx: MutationContext, schema: Schema) -> set[str]:
252
264
  types = set(get_type(schema))
253
- if context.is_path_location:
265
+ if ctx.is_path_location:
254
266
  candidates = {"string", "integer", "number", "boolean", "null"} - types
255
267
  else:
256
268
  candidates = {"string", "integer", "number", "object", "array", "boolean", "null"} - types
@@ -279,7 +291,7 @@ def drop_not_type_specific_keywords(schema: Schema, new_type: str) -> None:
279
291
 
280
292
 
281
293
  @for_types("object")
282
- def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
294
+ def change_properties(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
283
295
  """Mutate individual object schema properties.
284
296
 
285
297
  Effect: Some properties will not validate the original schema
@@ -290,9 +302,12 @@ def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> M
290
302
  return MutationResult.FAILURE
291
303
  # Order properties randomly and iterate over them until at least one mutation is successfully applied to at least
292
304
  # one property
293
- ordered_properties = draw(ordered(properties, unique_by=lambda x: x[0]))
305
+ ordered_properties = [
306
+ (name, canonicalish(subschema) if isinstance(subschema, bool) else subschema)
307
+ for name, subschema in draw(ordered(properties, unique_by=lambda x: x[0]))
308
+ ]
294
309
  for property_name, property_schema in ordered_properties:
295
- if apply_until_success(context, draw, property_schema) == MutationResult.SUCCESS:
310
+ if apply_until_success(ctx, draw, property_schema) == MutationResult.SUCCESS:
296
311
  # It is still possible to generate "positive" cases, for example, when this property is optional.
297
312
  # They are filtered out on the upper level anyway, but to avoid performance penalty we adjust the schema
298
313
  # so the generated samples are less likely to be "positive"
@@ -318,19 +333,19 @@ def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> M
318
333
  if enabled_properties.is_enabled(name):
319
334
  for mutation in get_mutations(draw, property_schema):
320
335
  if enabled_mutations.is_enabled(mutation.__name__):
321
- mutation(context, draw, property_schema)
336
+ mutation(ctx, draw, property_schema)
322
337
  return MutationResult.SUCCESS
323
338
 
324
339
 
325
- def apply_until_success(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
340
+ def apply_until_success(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
326
341
  for mutation in get_mutations(draw, schema):
327
- if mutation(context, draw, schema) == MutationResult.SUCCESS:
342
+ if mutation(ctx, draw, schema) == MutationResult.SUCCESS:
328
343
  return MutationResult.SUCCESS
329
344
  return MutationResult.FAILURE
330
345
 
331
346
 
332
347
  @for_types("array")
333
- def change_items(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
348
+ def change_items(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
334
349
  """Mutate individual array items.
335
350
 
336
351
  Effect: Some items will not validate the original schema
@@ -339,17 +354,25 @@ def change_items(context: MutationContext, draw: Draw, schema: Schema) -> Mutati
339
354
  if not items:
340
355
  # No items to mutate
341
356
  return MutationResult.FAILURE
357
+ # For query/path/header/cookie, string items cannot be meaningfully mutated
358
+ # because all types serialize to strings anyway
359
+ if ctx.location.is_in_header or ctx.is_path_location or ctx.is_query_location:
360
+ items = schema.get("items", {})
361
+ if isinstance(items, dict):
362
+ items_types = get_type(items)
363
+ if "string" in items_types:
364
+ return MutationResult.FAILURE
342
365
  if isinstance(items, dict):
343
- return _change_items_object(context, draw, schema, items)
366
+ return _change_items_object(ctx, draw, schema, items)
344
367
  if isinstance(items, list):
345
- return _change_items_array(context, draw, schema, items)
368
+ return _change_items_array(ctx, draw, schema, items)
346
369
  return MutationResult.FAILURE
347
370
 
348
371
 
349
- def _change_items_object(context: MutationContext, draw: Draw, schema: Schema, items: Schema) -> MutationResult:
372
+ def _change_items_object(ctx: MutationContext, draw: Draw, schema: Schema, items: Schema) -> MutationResult:
350
373
  result = MutationResult.FAILURE
351
374
  for mutation in get_mutations(draw, items):
352
- result |= mutation(context, draw, items)
375
+ result |= mutation(ctx, draw, items)
353
376
  if result == MutationResult.FAILURE:
354
377
  return MutationResult.FAILURE
355
378
  min_items = schema.get("minItems", 0)
@@ -357,12 +380,12 @@ def _change_items_object(context: MutationContext, draw: Draw, schema: Schema, i
357
380
  return MutationResult.SUCCESS
358
381
 
359
382
 
360
- def _change_items_array(context: MutationContext, draw: Draw, schema: Schema, items: list) -> MutationResult:
383
+ def _change_items_array(ctx: MutationContext, draw: Draw, schema: Schema, items: list) -> MutationResult:
361
384
  latest_success_index = None
362
385
  for idx, item in enumerate(items):
363
386
  result = MutationResult.FAILURE
364
387
  for mutation in get_mutations(draw, item):
365
- result |= mutation(context, draw, item)
388
+ result |= mutation(ctx, draw, item)
366
389
  if result == MutationResult.SUCCESS:
367
390
  latest_success_index = idx
368
391
  if latest_success_index is None:
@@ -372,7 +395,7 @@ def _change_items_array(context: MutationContext, draw: Draw, schema: Schema, it
372
395
  return MutationResult.SUCCESS
373
396
 
374
397
 
375
- def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
398
+ def negate_constraints(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
376
399
  """Negate schema constrains while keeping the original type."""
377
400
  if not can_negate(schema):
378
401
  return MutationResult.FAILURE
@@ -386,12 +409,12 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
386
409
  return v != []
387
410
  if k in ("example", "examples"):
388
411
  return False
389
- if context.is_path_location and k == "minLength" and v == 1:
412
+ if ctx.is_path_location and k == "minLength" and v == 1:
390
413
  # Empty path parameter will be filtered out
391
414
  return False
392
415
  return not (
393
416
  k in ("type", "properties", "items", "minItems")
394
- or (k == "additionalProperties" and context.is_header_location)
417
+ or (k == "additionalProperties" and ctx.location.is_in_header)
395
418
  )
396
419
 
397
420
  enabled_keywords = draw(st.shared(FeatureStrategy(), key="keywords")) # type: ignore
@@ -425,12 +448,17 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
425
448
  DEPENDENCIES = {"exclusiveMaximum": "maximum", "exclusiveMinimum": "minimum"}
426
449
 
427
450
 
428
- def get_mutations(draw: Draw, schema: Schema) -> tuple[Mutation, ...]:
451
+ def get_mutations(draw: Draw, schema: JsonSchemaObject) -> tuple[Mutation, ...]:
429
452
  """Get mutations possible for a schema."""
430
453
  types = get_type(schema)
431
454
  # On the top-level of Open API schemas, types are always strings, but inside "schema" objects, they are the same as
432
455
  # in JSON Schema, where it could be either a string or an array of strings.
433
- options: list[Mutation] = [negate_constraints, change_type]
456
+ options: list[Mutation]
457
+ if list(schema) == ["type"]:
458
+ # When there is only `type` in schema then `negate_constraints` is not applicable
459
+ options = [change_type]
460
+ else:
461
+ options = [negate_constraints, change_type]
434
462
  if "object" in types:
435
463
  options.extend([change_properties, remove_required_property])
436
464
  elif "array" in types:
@@ -2,23 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import sys
4
4
  from functools import lru_cache
5
- from typing import Any, Callable, Dict, Union, overload
5
+ from typing import Any, Callable, Dict, Union
6
6
  from urllib.request import urlopen
7
7
 
8
8
  import requests
9
9
 
10
10
  from schemathesis.core.compat import RefResolutionError, RefResolver
11
11
  from schemathesis.core.deserialization import deserialize_yaml
12
- from schemathesis.core.transforms import deepclone
13
12
  from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
14
13
 
15
- from .constants import ALL_KEYWORDS
16
- from .converter import to_json_schema_recursive
17
- from .utils import get_type
18
-
19
- # Reference resolving will stop after this depth
20
- RECURSION_DEPTH_LIMIT = 100
21
-
22
14
 
23
15
  def load_file_impl(location: str, opener: Callable) -> dict[str, Any]:
24
16
  """Load a schema from the given file."""
@@ -47,9 +39,7 @@ def load_remote_uri(uri: str) -> Any:
47
39
  JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
48
40
 
49
41
 
50
- class InliningResolver(RefResolver):
51
- """Inlines resolved schemas."""
52
-
42
+ class ReferenceResolver(RefResolver):
53
43
  def __init__(self, *args: Any, **kwargs: Any) -> None:
54
44
  kwargs.setdefault(
55
45
  "handlers", {"file": load_file_uri, "": load_file, "http": load_remote_uri, "https": load_remote_uri}
@@ -72,166 +62,3 @@ class InliningResolver(RefResolver):
72
62
  except RefResolutionError as exc:
73
63
  exc.__notes__ = [ref]
74
64
  raise
75
-
76
- @overload
77
- def resolve_all(self, item: dict[str, Any], recursion_level: int = 0) -> dict[str, Any]: ...
78
-
79
- @overload
80
- def resolve_all(self, item: list, recursion_level: int = 0) -> list: ...
81
-
82
- def resolve_all(self, item: JSONType, recursion_level: int = 0) -> JSONType:
83
- """Recursively resolve all references in the given object."""
84
- resolve = self.resolve_all
85
- if isinstance(item, dict):
86
- ref = item.get("$ref")
87
- if isinstance(ref, str):
88
- url, resolved = self.resolve(ref)
89
- self.push_scope(url)
90
- try:
91
- # If the next level of recursion exceeds the limit, then we need to copy it explicitly
92
- # In other cases, this method create new objects for mutable types (dict & list)
93
- next_recursion_level = recursion_level + 1
94
- if next_recursion_level > RECURSION_DEPTH_LIMIT:
95
- copied = deepclone(resolved)
96
- remove_optional_references(copied)
97
- return copied
98
- return resolve(resolved, next_recursion_level)
99
- finally:
100
- self.pop_scope()
101
- return {
102
- key: resolve(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
103
- for key, sub_item in item.items()
104
- }
105
- if isinstance(item, list):
106
- return [
107
- self.resolve_all(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
108
- for sub_item in item
109
- ]
110
- return item
111
-
112
- def resolve_in_scope(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any]]:
113
- scopes = [scope]
114
- # if there is `$ref` then we have a scope change that should be used during validation later to
115
- # resolve nested references correctly
116
- if "$ref" in definition:
117
- self.push_scope(scope)
118
- try:
119
- new_scope, definition = self.resolve(definition["$ref"])
120
- finally:
121
- self.pop_scope()
122
- scopes.append(new_scope)
123
- return scopes, definition
124
-
125
-
126
- class ConvertingResolver(InliningResolver):
127
- """Convert resolved OpenAPI schemas to JSON Schema.
128
-
129
- When recursive schemas are validated we need to have resolved documents properly converted.
130
- This approach is the simplest one, since this logic isolated in a single place.
131
- """
132
-
133
- def __init__(self, *args: Any, nullable_name: Any, is_response_schema: bool = False, **kwargs: Any) -> None:
134
- super().__init__(*args, **kwargs)
135
- self.nullable_name = nullable_name
136
- self.is_response_schema = is_response_schema
137
-
138
- def resolve(self, ref: str) -> tuple[str, Any]:
139
- url, document = super().resolve(ref)
140
- document = to_json_schema_recursive(
141
- document,
142
- nullable_name=self.nullable_name,
143
- is_response_schema=self.is_response_schema,
144
- update_quantifiers=False,
145
- )
146
- return url, document
147
-
148
-
149
- def remove_optional_references(schema: dict[str, Any]) -> None:
150
- """Remove optional parts of the schema that contain references.
151
-
152
- It covers only the most popular cases, as removing all optional parts is complicated.
153
- We might fall back to filtering out invalid cases in the future.
154
- """
155
-
156
- def clean_properties(s: dict[str, Any]) -> None:
157
- properties = s["properties"]
158
- required = s.get("required", [])
159
- for name, value in list(properties.items()):
160
- if name not in required and contains_ref(value):
161
- # Drop the property - it will not be generated
162
- del properties[name]
163
- elif on_single_item_combinators(value):
164
- properties.pop(name, None)
165
- else:
166
- stack.append(value)
167
-
168
- def clean_items(s: dict[str, Any]) -> None:
169
- items = s["items"]
170
- min_items = s.get("minItems", 0)
171
- if not min_items:
172
- if isinstance(items, dict) and ("$ref" in items or on_single_item_combinators(items)):
173
- force_empty_list(s)
174
- if isinstance(items, list) and any_ref(items):
175
- force_empty_list(s)
176
-
177
- def clean_additional_properties(s: dict[str, Any]) -> None:
178
- additional_properties = s["additionalProperties"]
179
- if isinstance(additional_properties, dict) and "$ref" in additional_properties:
180
- s["additionalProperties"] = False
181
-
182
- def force_empty_list(s: dict[str, Any]) -> None:
183
- del s["items"]
184
- s["maxItems"] = 0
185
-
186
- def any_ref(i: list[dict[str, Any]]) -> bool:
187
- return any("$ref" in item for item in i)
188
-
189
- def contains_ref(s: dict[str, Any]) -> bool:
190
- if "$ref" in s:
191
- return True
192
- i = s.get("items")
193
- return (isinstance(i, dict) and "$ref" in i) or isinstance(i, list) and any_ref(i)
194
-
195
- def can_elide(s: dict[str, Any]) -> bool:
196
- # Whether this schema could be dropped from a list of schemas
197
- type_ = get_type(s)
198
- if type_ == ["object"]:
199
- # Empty object is valid for this schema -> could be dropped
200
- return s.get("required", []) == [] and s.get("minProperties", 0) == 0
201
- # Has at least one keyword -> should not be removed
202
- return not any(k in ALL_KEYWORDS for k in s)
203
-
204
- def on_single_item_combinators(s: dict[str, Any]) -> list[str]:
205
- # Schema example:
206
- # {
207
- # "type": "object",
208
- # "properties": {
209
- # "parent": {
210
- # "allOf": [{"$ref": "#/components/schemas/User"}]
211
- # }
212
- # }
213
- # }
214
- found = []
215
- for keyword in ("allOf", "oneOf", "anyOf"):
216
- v = s.get(keyword)
217
- if v is not None:
218
- elided = [sub for sub in v if not can_elide(sub)]
219
- if len(elided) == 1 and contains_ref(elided[0]):
220
- found.append(keyword)
221
- return found
222
-
223
- stack = [schema]
224
- while stack:
225
- definition = stack.pop()
226
- if isinstance(definition, dict):
227
- # Optional properties
228
- if "properties" in definition:
229
- clean_properties(definition)
230
- # Optional items
231
- if "items" in definition:
232
- clean_items(definition)
233
- # Not required additional properties
234
- if "additionalProperties" in definition:
235
- clean_additional_properties(definition)
236
- for k in on_single_item_combinators(definition):
237
- del definition[k]