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
@@ -4,16 +4,17 @@ import enum
4
4
  import http.client
5
5
  from dataclasses import dataclass
6
6
  from http.cookies import SimpleCookie
7
- from typing import TYPE_CHECKING, Any, Dict, Generator, NoReturn, cast
7
+ from typing import TYPE_CHECKING, Any, Iterator, Mapping, NoReturn, cast
8
8
  from urllib.parse import parse_qs, urlparse
9
9
 
10
10
  import schemathesis
11
11
  from schemathesis.checks import CheckContext
12
12
  from schemathesis.core import media_types, string_to_boolean
13
13
  from schemathesis.core.failures import Failure
14
+ from schemathesis.core.parameters import ParameterLocation
14
15
  from schemathesis.core.transport import Response
15
16
  from schemathesis.generation.case import Case
16
- from schemathesis.generation.meta import ComponentKind, CoveragePhaseData
17
+ from schemathesis.generation.meta import CoveragePhaseData
17
18
  from schemathesis.openapi.checks import (
18
19
  AcceptedNegativeData,
19
20
  EnsureResourceAvailability,
@@ -29,13 +30,13 @@ from schemathesis.openapi.checks import (
29
30
  UnsupportedMethodResponse,
30
31
  UseAfterFree,
31
32
  )
32
- from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
33
33
  from schemathesis.transport.prepare import prepare_path
34
34
 
35
35
  from .utils import expand_status_code, expand_status_codes
36
36
 
37
37
  if TYPE_CHECKING:
38
38
  from schemathesis.schemas import APIOperation
39
+ from schemathesis.specs.openapi.adapter.parameters import OpenApiParameterSet
39
40
 
40
41
 
41
42
  def is_unexpected_http_status_case(case: Case) -> bool:
@@ -53,13 +54,13 @@ def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -
53
54
 
54
55
  if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
55
56
  return True
56
- responses = case.operation.definition.raw.get("responses", {})
57
+ status_codes = case.operation.responses.status_codes
57
58
  # "default" can be used as the default response object for all HTTP codes that are not covered individually
58
- if "default" in responses:
59
+ if "default" in status_codes:
59
60
  return None
60
- allowed_status_codes = list(_expand_responses(responses))
61
+ allowed_status_codes = list(_expand_status_codes(status_codes))
61
62
  if response.status_code not in allowed_status_codes:
62
- defined_status_codes = list(map(str, responses))
63
+ defined_status_codes = list(map(str, status_codes))
63
64
  responses_list = ", ".join(defined_status_codes)
64
65
  raise UndefinedStatusCode(
65
66
  operation=case.operation.label,
@@ -71,7 +72,7 @@ def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -
71
72
  return None # explicitly return None for mypy
72
73
 
73
74
 
74
- def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, None]:
75
+ def _expand_status_codes(responses: tuple[str, ...]) -> Iterator[int]:
75
76
  for code in responses:
76
77
  yield from expand_status_code(code)
77
78
 
@@ -131,60 +132,48 @@ def _reraise_malformed_media_type(case: Case, location: str, actual: str, define
131
132
  def response_headers_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
132
133
  import jsonschema
133
134
 
134
- from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
135
- from .schemas import BaseOpenAPISchema, OpenApi30, _maybe_raise_one_or_more
135
+ from .schemas import BaseOpenAPISchema, _maybe_raise_one_or_more
136
136
 
137
137
  if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
138
138
  return True
139
- resolved = case.operation.schema.get_headers(case.operation, response)
140
- if not resolved:
139
+
140
+ # Find the matching response definition
141
+ response_definition = case.operation.responses.find_by_status_code(response.status_code)
142
+ if response_definition is None:
141
143
  return None
142
- scopes, defined_headers = resolved
143
- if not defined_headers:
144
+ # Check whether the matching response definition has headers defined
145
+ headers = response_definition.headers
146
+ if not headers:
144
147
  return None
145
148
 
146
- missing_headers = [
147
- header
148
- for header, definition in defined_headers.items()
149
- if header.lower() not in response.headers and definition.get(case.operation.schema.header_required_field, False)
150
- ]
151
149
  errors: list[Failure] = []
152
- if missing_headers:
153
- formatted_headers = [f"\n- `{header}`" for header in missing_headers]
154
- message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
155
- errors.append(MissingHeaders(operation=case.operation.label, message=message, missing_headers=missing_headers))
156
- for name, definition in defined_headers.items():
150
+
151
+ missing_headers = []
152
+
153
+ for name, header in headers.items():
157
154
  values = response.headers.get(name.lower())
158
155
  if values is not None:
159
156
  value = values[0]
160
- with case.operation.schema._validating_response(scopes) as resolver:
161
- if "$ref" in definition:
162
- _, definition = resolver.resolve(definition["$ref"])
163
- parameter_definition = {"in": "header", **definition}
164
- parameter: OpenAPI20Parameter | OpenAPI30Parameter
165
- if isinstance(case.operation.schema, OpenApi30):
166
- parameter = OpenAPI30Parameter(parameter_definition)
167
- else:
168
- parameter = OpenAPI20Parameter(parameter_definition)
169
- schema = parameter.as_json_schema(case.operation)
170
- coerced = _coerce_header_value(value, schema)
171
- try:
172
- jsonschema.validate(
173
- coerced,
174
- schema,
175
- cls=case.operation.schema.validator_cls,
176
- resolver=resolver,
177
- format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
178
- )
179
- except jsonschema.ValidationError as exc:
180
- errors.append(
181
- JsonSchemaError.from_exception(
182
- title="Response header does not conform to the schema",
183
- operation=case.operation.label,
184
- exc=exc,
185
- config=case.operation.schema.config.output,
186
- )
157
+ coerced = _coerce_header_value(value, header.schema)
158
+ try:
159
+ header.validator.validate(coerced)
160
+ except jsonschema.ValidationError as exc:
161
+ errors.append(
162
+ JsonSchemaError.from_exception(
163
+ title="Response header does not conform to the schema",
164
+ operation=case.operation.label,
165
+ exc=exc,
166
+ config=case.operation.schema.config.output,
187
167
  )
168
+ )
169
+ elif header.is_required:
170
+ missing_headers.append(name)
171
+
172
+ if missing_headers:
173
+ formatted_headers = [f"\n- `{header}`" for header in missing_headers]
174
+ message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
175
+ errors.append(MissingHeaders(operation=case.operation.label, message=message, missing_headers=missing_headers))
176
+
188
177
  return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
189
178
 
190
179
 
@@ -279,7 +268,7 @@ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -
279
268
  data = meta.phase.data
280
269
  if (
281
270
  data.parameter
282
- and data.parameter_location == "header"
271
+ and data.parameter_location == ParameterLocation.HEADER
283
272
  and data.description
284
273
  and data.description.startswith("Missing ")
285
274
  ):
@@ -334,25 +323,30 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
334
323
  # This function is used to determine if negation is solely in the form of extra properties,
335
324
  # which are often ignored for backward-compatibility by the tested apps
336
325
  from ._hypothesis import get_schema_for_location
326
+ from .schemas import BaseOpenAPISchema
337
327
 
338
328
  meta = case.meta
339
- if meta is None:
329
+ if meta is None or not isinstance(case.operation.schema, BaseOpenAPISchema):
340
330
  # Ignore manually created cases
341
331
  return False
342
- if (ComponentKind.BODY in meta.components and meta.components[ComponentKind.BODY].mode.is_negative) or (
343
- ComponentKind.PATH_PARAMETERS in meta.components
344
- and meta.components[ComponentKind.PATH_PARAMETERS].mode.is_negative
332
+ if (ParameterLocation.BODY in meta.components and meta.components[ParameterLocation.BODY].mode.is_negative) or (
333
+ ParameterLocation.PATH in meta.components and meta.components[ParameterLocation.PATH].mode.is_negative
345
334
  ):
346
335
  # Body or path negations always imply other negations
347
336
  return False
348
- validator_cls = case.operation.schema.validator_cls # type: ignore[attr-defined]
349
- for container in (ComponentKind.QUERY, ComponentKind.HEADERS, ComponentKind.COOKIES):
350
- meta_for_location = meta.components.get(container)
351
- value = getattr(case, container.value)
337
+ validator_cls = case.operation.schema.adapter.jsonschema_validator_cls
338
+ for location in (ParameterLocation.QUERY, ParameterLocation.HEADER, ParameterLocation.COOKIE):
339
+ meta_for_location = meta.components.get(location)
340
+ value = getattr(case, location.container_name)
352
341
  if value is not None and meta_for_location is not None and meta_for_location.mode.is_negative:
353
- parameters = getattr(case.operation, container)
354
- value_without_additional_properties = {k: v for k, v in value.items() if k in parameters}
355
- schema = get_schema_for_location(case.operation, container, parameters)
342
+ container = getattr(case.operation, location.container_name)
343
+ schema = get_schema_for_location(location, container)
344
+
345
+ if _has_serialization_sensitive_types(schema, container):
346
+ # Can't reliably determine if only additional properties were added
347
+ continue
348
+
349
+ value_without_additional_properties = {k: v for k, v in value.items() if k in container}
356
350
  if not validator_cls(schema).is_valid(value_without_additional_properties):
357
351
  # Other types of negation found
358
352
  return False
@@ -360,6 +354,30 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
360
354
  return True
361
355
 
362
356
 
357
+ def _has_serialization_sensitive_types(schema: dict, container: OpenApiParameterSet) -> bool:
358
+ """Check if schema contains array or object types in defined parameters.
359
+
360
+ In query/header/cookie parameters, arrays and objects are serialized to strings.
361
+ This makes post-serialization validation against the original schema unreliable:
362
+
363
+ - Generated: ["foo", "bar"] (array)
364
+ - Serialized: "foo,bar" (string)
365
+
366
+ Validation of string against array schema fails incorrectly.
367
+ A better approach would be to apply serialization later on in the process.
368
+
369
+ """
370
+ from schemathesis.core.jsonschema import get_type
371
+
372
+ properties = schema.get("properties", {})
373
+ for prop_name, prop_schema in properties.items():
374
+ if prop_name in container:
375
+ types = get_type(prop_schema)
376
+ if "array" in types or "object" in types:
377
+ return True
378
+ return False
379
+
380
+
363
381
  @schemathesis.check
364
382
  def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool | None:
365
383
  from .schemas import BaseOpenAPISchema
@@ -439,7 +457,7 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
439
457
  overrides = case._override
440
458
  overrides_all_parameters = True
441
459
  for parameter in case.operation.iter_parameters():
442
- container = LOCATION_TO_CONTAINER[parameter.location]
460
+ container = parameter.location.container_name
443
461
  if parameter.name not in getattr(overrides, container, {}):
444
462
  overrides_all_parameters = False
445
463
  break
@@ -495,12 +513,14 @@ class AuthKind(str, enum.Enum):
495
513
  @schemathesis.check
496
514
  def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | None:
497
515
  """Check if an operation declares authentication as a requirement but does not actually enforce it."""
498
- from .schemas import BaseOpenAPISchema
516
+ from schemathesis.specs.openapi.adapter.security import has_optional_auth
517
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
499
518
 
519
+ operation = case.operation
500
520
  if (
501
- not isinstance(case.operation.schema, BaseOpenAPISchema)
521
+ not isinstance(operation.schema, BaseOpenAPISchema)
502
522
  or is_unexpected_http_status_case(case)
503
- or _has_optional_auth(case.operation)
523
+ or has_optional_auth(operation.schema.raw_schema, operation.definition.raw)
504
524
  ):
505
525
  return True
506
526
  security_parameters = _get_security_parameters(case.operation)
@@ -574,30 +594,19 @@ def _raise_no_auth_error(response: Response, case: Case, auth: AuthScenario) ->
574
594
  )
575
595
 
576
596
 
577
- SecurityParameter = Dict[str, Any]
578
-
579
-
580
- def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]:
597
+ def _get_security_parameters(operation: APIOperation) -> list[Mapping[str, Any]]:
581
598
  """Extract security definitions that are active for the given operation and convert them into parameters."""
582
- from .schemas import BaseOpenAPISchema
599
+ from schemathesis.specs.openapi.adapter.security import ORIGINAL_SECURITY_TYPE_KEY
583
600
 
584
- schema = cast(BaseOpenAPISchema, operation.schema)
585
601
  return [
586
- schema.security._to_parameter(parameter)
587
- for parameter in schema.security._get_active_definitions(schema.raw_schema, operation, schema.resolver)
588
- if parameter["type"] in ("apiKey", "basic", "http")
602
+ param
603
+ for param in operation.security.iter_parameters()
604
+ if param[ORIGINAL_SECURITY_TYPE_KEY] in ["apiKey", "basic", "http"]
589
605
  ]
590
606
 
591
607
 
592
- def _has_optional_auth(operation: APIOperation) -> bool:
593
- from .schemas import BaseOpenAPISchema
594
-
595
- schema = cast(BaseOpenAPISchema, operation.schema)
596
- return schema.security.has_optional_auth(schema.raw_schema, operation)
597
-
598
-
599
608
  def _contains_auth(
600
- ctx: CheckContext, case: Case, response: Response, security_parameters: list[SecurityParameter]
609
+ ctx: CheckContext, case: Case, response: Response, security_parameters: list[Mapping[str, Any]]
601
610
  ) -> AuthKind | None:
602
611
  """Whether a request has authentication declared in the schema."""
603
612
  from requests.cookies import RequestsCookieJar
@@ -614,13 +623,13 @@ def _contains_auth(
614
623
  if raw_cookie is not None:
615
624
  header_cookies.load(raw_cookie)
616
625
 
617
- def has_header(p: dict[str, Any]) -> bool:
626
+ def has_header(p: Mapping[str, Any]) -> bool:
618
627
  return p["in"] == "header" and p["name"] in request.headers
619
628
 
620
- def has_query(p: dict[str, Any]) -> bool:
629
+ def has_query(p: Mapping[str, Any]) -> bool:
621
630
  return p["in"] == "query" and p["name"] in query
622
631
 
623
- def has_cookie(p: dict[str, Any]) -> bool:
632
+ def has_cookie(p: Mapping[str, Any]) -> bool:
624
633
  cookies = cast(RequestsCookieJar, request._cookies) # type: ignore
625
634
  return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
626
635
 
@@ -662,7 +671,7 @@ def _contains_auth(
662
671
  return None
663
672
 
664
673
 
665
- def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Case:
674
+ def remove_auth(case: Case, security_parameters: list[Mapping[str, Any]]) -> Case:
666
675
  """Remove security parameters from a generated case.
667
676
 
668
677
  It mutates `case` in place.
@@ -692,14 +701,14 @@ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Cas
692
701
  )
693
702
 
694
703
 
695
- def _remove_auth_from_container(container: dict, security_parameters: list[SecurityParameter], location: str) -> None:
704
+ def _remove_auth_from_container(container: dict, security_parameters: list[Mapping[str, Any]], location: str) -> None:
696
705
  for parameter in security_parameters:
697
706
  name = parameter["name"]
698
707
  if parameter["in"] == location:
699
708
  container.pop(name, None)
700
709
 
701
710
 
702
- def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
711
+ def _set_auth_for_case(case: Case, parameter: Mapping[str, Any]) -> None:
703
712
  name = parameter["name"]
704
713
  for location, attr_name in (
705
714
  ("header", "headers"),
@@ -1,31 +1,69 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from itertools import chain
4
- from typing import Any, Callable
4
+ from typing import Any, Callable, overload
5
5
 
6
- from schemathesis.core.transforms import deepclone, transform
7
-
8
- from .patterns import update_quantifier
6
+ from schemathesis.core.jsonschema.bundler import BUNDLE_STORAGE_KEY
7
+ from schemathesis.core.jsonschema.types import JsonSchema
8
+ from schemathesis.core.transforms import deepclone
9
+ from schemathesis.specs.openapi.patterns import update_quantifier
9
10
 
10
11
 
12
+ @overload
11
13
  def to_json_schema(
12
14
  schema: dict[str, Any],
13
- *,
14
- nullable_name: str,
15
- copy: bool = True,
15
+ nullable_keyword: str,
16
16
  is_response_schema: bool = False,
17
17
  update_quantifiers: bool = True,
18
- ) -> dict[str, Any]:
19
- """Convert Open API parameters to JSON Schema.
18
+ clone: bool = True,
19
+ ) -> dict[str, Any]: ... # pragma: no cover
20
+
20
21
 
21
- NOTE. This function is applied to all keywords (including nested) during a schema resolving, thus it is not recursive.
22
- See a recursive version below.
23
- """
24
- if copy:
22
+ @overload
23
+ def to_json_schema(
24
+ schema: bool,
25
+ nullable_keyword: str,
26
+ is_response_schema: bool = False,
27
+ update_quantifiers: bool = True,
28
+ clone: bool = True,
29
+ ) -> bool: ... # pragma: no cover
30
+
31
+
32
+ def to_json_schema(
33
+ schema: dict[str, Any] | bool,
34
+ nullable_keyword: str,
35
+ is_response_schema: bool = False,
36
+ update_quantifiers: bool = True,
37
+ clone: bool = True,
38
+ ) -> dict[str, Any] | bool:
39
+ if isinstance(schema, bool):
40
+ return schema
41
+ if clone:
25
42
  schema = deepclone(schema)
26
- if schema.get(nullable_name) is True:
27
- del schema[nullable_name]
43
+ return _to_json_schema(
44
+ schema,
45
+ nullable_keyword=nullable_keyword,
46
+ is_response_schema=is_response_schema,
47
+ update_quantifiers=update_quantifiers,
48
+ )
49
+
50
+
51
+ def _to_json_schema(
52
+ schema: JsonSchema,
53
+ *,
54
+ nullable_keyword: str,
55
+ is_response_schema: bool = False,
56
+ update_quantifiers: bool = True,
57
+ ) -> JsonSchema:
58
+ if isinstance(schema, bool):
59
+ return schema
60
+
61
+ if schema.get(nullable_keyword) is True:
62
+ del schema[nullable_keyword]
63
+ bundled = schema.pop(BUNDLE_STORAGE_KEY, None)
28
64
  schema = {"anyOf": [schema, {"type": "null"}]}
65
+ if bundled:
66
+ schema[BUNDLE_STORAGE_KEY] = bundled
29
67
  schema_type = schema.get("type")
30
68
  if schema_type == "file":
31
69
  schema["type"] = "string"
@@ -54,9 +92,70 @@ def to_json_schema(
54
92
  else:
55
93
  # Read-only properties should not occur in requests
56
94
  rewrite_properties(schema, is_read_only)
95
+
96
+ for keyword, value in schema.items():
97
+ if keyword in IN_VALUE and isinstance(value, dict):
98
+ schema[keyword] = _to_json_schema(
99
+ value,
100
+ nullable_keyword=nullable_keyword,
101
+ is_response_schema=is_response_schema,
102
+ update_quantifiers=update_quantifiers,
103
+ )
104
+ elif keyword in IN_ITEM and isinstance(value, list):
105
+ for idx, subschema in enumerate(value):
106
+ value[idx] = _to_json_schema(
107
+ subschema,
108
+ nullable_keyword=nullable_keyword,
109
+ is_response_schema=is_response_schema,
110
+ update_quantifiers=update_quantifiers,
111
+ )
112
+ elif keyword in IN_CHILD and isinstance(value, dict):
113
+ for name, subschema in value.items():
114
+ value[name] = _to_json_schema(
115
+ subschema,
116
+ nullable_keyword=nullable_keyword,
117
+ is_response_schema=is_response_schema,
118
+ update_quantifiers=update_quantifiers,
119
+ )
120
+
57
121
  return schema
58
122
 
59
123
 
124
+ IN_VALUE = frozenset(
125
+ (
126
+ "additionalProperties",
127
+ "contains",
128
+ "contentSchema",
129
+ "else",
130
+ "if",
131
+ "items",
132
+ "not",
133
+ "propertyNames",
134
+ "then",
135
+ "unevaluatedItems",
136
+ "unevaluatedProperties",
137
+ )
138
+ )
139
+ IN_ITEM = frozenset(
140
+ (
141
+ "allOf",
142
+ "anyOf",
143
+ "oneOf",
144
+ )
145
+ )
146
+ IN_CHILD = frozenset(
147
+ (
148
+ "prefixItems",
149
+ "$defs",
150
+ "definitions",
151
+ "dependentSchemas",
152
+ "patternProperties",
153
+ "properties",
154
+ BUNDLE_STORAGE_KEY,
155
+ )
156
+ )
157
+
158
+
60
159
  def update_pattern_in_schema(schema: dict[str, Any]) -> None:
61
160
  pattern = schema.get("pattern")
62
161
  min_length = schema.get("minLength")
@@ -104,15 +203,3 @@ def is_read_only(schema: dict[str, Any] | bool) -> bool:
104
203
  if isinstance(schema, bool):
105
204
  return False
106
205
  return schema.get("readOnly", False)
107
-
108
-
109
- def to_json_schema_recursive(
110
- schema: dict[str, Any], nullable_name: str, is_response_schema: bool = False, update_quantifiers: bool = True
111
- ) -> dict[str, Any]:
112
- return transform(
113
- schema,
114
- to_json_schema,
115
- nullable_name=nullable_name,
116
- is_response_schema=is_response_schema,
117
- update_quantifiers=update_quantifiers,
118
- )