schemathesis 4.1.3__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 +89 -13
  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.3.dist-info → schemathesis-4.2.0.dist-info}/METADATA +2 -2
  65. {schemathesis-4.1.3.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.3.dist-info → schemathesis-4.2.0.dist-info}/WHEEL +0 -0
  69. {schemathesis-4.1.3.dist-info → schemathesis-4.2.0.dist-info}/entry_points.txt +0 -0
  70. {schemathesis-4.1.3.dist-info → schemathesis-4.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -105,7 +105,7 @@ def initialize_handlers(
105
105
  if report.enabled:
106
106
  path = config.reports.get_path(format)
107
107
  open_file(path)
108
- handlers.append(CassetteWriter(format=format, path=path, config=config))
108
+ handlers.append(CassetteWriter(format=format, output=path, config=config))
109
109
 
110
110
  for custom_handler in CUSTOM_HANDLERS:
111
111
  handlers.append(custom_handler(*args, **params))
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING, Any
3
+ from contextlib import contextmanager
4
+ from io import StringIO
5
+ from pathlib import Path
6
+ from typing import IO, TYPE_CHECKING, Any, Generator, Protocol, Union
4
7
 
5
8
  if TYPE_CHECKING:
6
9
  from schemathesis.cli.commands.run.context import ExecutionContext
@@ -16,3 +19,27 @@ class EventHandler:
16
19
  def start(self, ctx: ExecutionContext) -> None: ...
17
20
 
18
21
  def shutdown(self, ctx: ExecutionContext) -> None: ...
22
+
23
+
24
+ class WritableText(Protocol):
25
+ """Protocol for text-writable file-like objects."""
26
+
27
+ def write(self, s: str) -> int: ... # pragma: no cover
28
+ def flush(self) -> None: ... # pragma: no cover
29
+
30
+
31
+ TextOutput = Union[IO[str], StringIO, Path]
32
+
33
+
34
+ @contextmanager
35
+ def open_text_output(output: TextOutput) -> Generator[IO[str]]:
36
+ """Open a text output, handling both Path and file-like objects."""
37
+ if isinstance(output, Path):
38
+ f = open(output, "w", encoding="utf-8")
39
+ try:
40
+ yield f
41
+ finally:
42
+ f.close()
43
+ else:
44
+ # Assume it's already a file-like object
45
+ yield output # type: ignore[misc]
@@ -6,7 +6,6 @@ import sys
6
6
  import threading
7
7
  from dataclasses import dataclass
8
8
  from http.cookies import SimpleCookie
9
- from pathlib import Path
10
9
  from queue import Queue
11
10
  from typing import IO, Callable, Iterator
12
11
  from urllib.parse import parse_qsl, urlparse
@@ -14,7 +13,7 @@ from urllib.parse import parse_qsl, urlparse
14
13
  import harfile
15
14
 
16
15
  from schemathesis.cli.commands.run.context import ExecutionContext
17
- from schemathesis.cli.commands.run.handlers.base import EventHandler
16
+ from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
18
17
  from schemathesis.config import ProjectConfig, ReportFormat, SchemathesisConfig
19
18
  from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
20
19
  from schemathesis.core.transforms import deepclone
@@ -33,27 +32,27 @@ class CassetteWriter(EventHandler):
33
32
  """Write network interactions to a cassette."""
34
33
 
35
34
  format: ReportFormat
36
- path: Path
35
+ output: TextOutput
37
36
  config: ProjectConfig
38
37
  queue: Queue
39
38
  worker: threading.Thread
40
39
 
41
- __slots__ = ("format", "path", "config", "queue", "worker")
40
+ __slots__ = ("format", "output", "config", "queue", "worker")
42
41
 
43
42
  def __init__(
44
43
  self,
45
44
  format: ReportFormat,
46
- path: Path,
45
+ output: TextOutput,
47
46
  config: ProjectConfig,
48
47
  queue: Queue | None = None,
49
48
  ) -> None:
50
49
  self.format = format
51
- self.path = path
50
+ self.output = output
52
51
  self.config = config
53
52
  self.queue = queue or Queue()
54
53
 
55
54
  kwargs = {
56
- "path": self.path,
55
+ "output": self.output,
57
56
  "config": self.config,
58
57
  "queue": self.queue,
59
58
  }
@@ -119,7 +118,7 @@ def get_command_representation() -> str:
119
118
  return f"st {args}"
120
119
 
121
120
 
122
- def vcr_writer(path: Path, config: ProjectConfig, queue: Queue) -> None:
121
+ def vcr_writer(output: TextOutput, config: ProjectConfig, queue: Queue) -> None:
123
122
  """Write YAML to a file in an incremental manner.
124
123
 
125
124
  This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
@@ -203,7 +202,7 @@ def vcr_writer(path: Path, config: ProjectConfig, queue: Queue) -> None:
203
202
  )
204
203
  write_double_quoted(output, string)
205
204
 
206
- with open(path, "w", encoding="utf-8") as stream:
205
+ with open_text_output(output) as stream:
207
206
  while True:
208
207
  item = queue.get()
209
208
  if isinstance(item, Initialize):
@@ -367,8 +366,8 @@ def write_double_quoted(stream: IO, text: str | None) -> None:
367
366
  stream.write('"')
368
367
 
369
368
 
370
- def har_writer(path: Path, config: SchemathesisConfig, queue: Queue) -> None:
371
- with harfile.open(path) as har:
369
+ def har_writer(output: TextOutput, config: SchemathesisConfig, queue: Queue) -> None:
370
+ with harfile.open(output) as har:
372
371
  while True:
373
372
  item = queue.get()
374
373
  if isinstance(item, Process):
@@ -454,7 +453,6 @@ def har_writer(path: Path, config: SchemathesisConfig, queue: Queue) -> None:
454
453
  )
455
454
  elif isinstance(item, Finalize):
456
455
  break
457
- har.flush()
458
456
 
459
457
 
460
458
  HARFILE_NO_RESPONSE = harfile.Response(
@@ -2,26 +2,25 @@ from __future__ import annotations
2
2
 
3
3
  import platform
4
4
  from dataclasses import dataclass
5
- from pathlib import Path
6
5
  from typing import Iterable
7
6
 
8
7
  from junit_xml import TestCase, TestSuite, to_xml_report_file
9
8
 
10
9
  from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
11
- from schemathesis.cli.commands.run.handlers.base import EventHandler
10
+ from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
12
11
  from schemathesis.core.failures import format_failures
13
12
  from schemathesis.engine import Status, events
14
13
 
15
14
 
16
15
  @dataclass
17
16
  class JunitXMLHandler(EventHandler):
18
- path: Path
17
+ output: TextOutput
19
18
  test_cases: dict
20
19
 
21
20
  __slots__ = ("path", "test_cases")
22
21
 
23
- def __init__(self, path: Path, test_cases: dict | None = None) -> None:
24
- self.path = path
22
+ def __init__(self, output: TextOutput, test_cases: dict | None = None) -> None:
23
+ self.output = output
25
24
  self.test_cases = test_cases or {}
26
25
 
27
26
  def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
@@ -40,7 +39,7 @@ class JunitXMLHandler(EventHandler):
40
39
  test_suites = [
41
40
  TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
42
41
  ]
43
- with open(self.path, "w", encoding="utf-8") as fd:
42
+ with open_text_output(self.output) as fd:
44
43
  to_xml_report_file(file_descriptor=fd, test_suites=test_suites, prettyprint=True, encoding="utf-8")
45
44
 
46
45
  def get_or_create_test_case(self, label: str) -> TestCase:
@@ -1053,6 +1053,8 @@ class OutputHandler(EventHandler):
1053
1053
  self._check_stateful_warnings(ctx, event)
1054
1054
 
1055
1055
  def _check_warnings(self, ctx: ExecutionContext, event: events.ScenarioFinished) -> None:
1056
+ from schemathesis.core.compat import RefResolutionError
1057
+
1056
1058
  statistic = aggregate_status_codes(event.recorder.interactions.values())
1057
1059
 
1058
1060
  if statistic.total == 0:
@@ -1060,7 +1062,11 @@ class OutputHandler(EventHandler):
1060
1062
 
1061
1063
  assert ctx.find_operation_by_label is not None
1062
1064
  assert event.label is not None
1063
- operation = ctx.find_operation_by_label(event.label)
1065
+ try:
1066
+ operation = ctx.find_operation_by_label(event.label)
1067
+ except RefResolutionError:
1068
+ # This error will be reported elsewhere anyway
1069
+ return None
1064
1070
 
1065
1071
  warnings = self.config.warnings_for(operation=operation)
1066
1072
 
@@ -12,5 +12,5 @@ def open_file(file: Path) -> None:
12
12
  raise click.BadParameter(f"'{file.name}': {exc.strerror}") from exc
13
13
  try:
14
14
  file.open("w", encoding="utf-8")
15
- except OSError as exc:
15
+ except (OSError, ValueError) as exc:
16
16
  raise click.BadParameter(f"Could not open file {file.name}: {exc}") from exc
@@ -69,12 +69,14 @@ class DiffBase:
69
69
  @classmethod
70
70
  def from_hierarchy(cls, configs: list[T]) -> T:
71
71
  # This config will accumulate "merged" config options
72
+ if len(configs) == 1:
73
+ return configs[0]
72
74
  output = cls()
73
75
  for option in cls.__slots__: # type: ignore
74
76
  if option.startswith("_"):
75
77
  continue
76
78
  default = getattr(output, option)
77
- if is_dataclass(default):
79
+ if hasattr(default, "__dataclass_fields__"):
78
80
  # Sub-configs require merging of nested config options
79
81
  sub_configs = [getattr(config, option) for config in configs]
80
82
  merged = type(default).from_hierarchy(sub_configs) # type: ignore[union-attr]
@@ -68,6 +68,8 @@ class OperationsConfig(DiffBase):
68
68
 
69
69
  def get_for_operation(self, operation: APIOperation) -> OperationConfig:
70
70
  configs = [config for config in self.operations if config._filter_set.applies_to(operation)]
71
+ if not configs:
72
+ return OperationConfig()
71
73
  return OperationConfig.from_hierarchy(configs)
72
74
 
73
75
  def create_filter_set(
@@ -17,7 +17,7 @@ class PhaseConfig(DiffBase):
17
17
  generation: GenerationConfig
18
18
  checks: ChecksConfig
19
19
 
20
- __slots__ = ("enabled", "generation", "checks")
20
+ __slots__ = ("enabled", "generation", "checks", "_is_default")
21
21
 
22
22
  def __init__(
23
23
  self,
@@ -29,6 +29,7 @@ class PhaseConfig(DiffBase):
29
29
  self.enabled = enabled
30
30
  self.generation = generation or GenerationConfig()
31
31
  self.checks = checks or ChecksConfig()
32
+ self._is_default = enabled and generation is None and checks is None
32
33
 
33
34
  @classmethod
34
35
  def from_dict(cls, data: dict[str, Any]) -> PhaseConfig:
@@ -46,7 +47,7 @@ class ExamplesPhaseConfig(DiffBase):
46
47
  generation: GenerationConfig
47
48
  checks: ChecksConfig
48
49
 
49
- __slots__ = ("enabled", "fill_missing", "generation", "checks")
50
+ __slots__ = ("enabled", "fill_missing", "generation", "checks", "_is_default")
50
51
 
51
52
  def __init__(
52
53
  self,
@@ -60,6 +61,7 @@ class ExamplesPhaseConfig(DiffBase):
60
61
  self.fill_missing = fill_missing
61
62
  self.generation = generation or GenerationConfig()
62
63
  self.checks = checks or ChecksConfig()
64
+ self._is_default = enabled and not fill_missing and generation is None and checks is None
63
65
 
64
66
  @classmethod
65
67
  def from_dict(cls, data: dict[str, Any]) -> ExamplesPhaseConfig:
@@ -79,7 +81,14 @@ class CoveragePhaseConfig(DiffBase):
79
81
  checks: ChecksConfig
80
82
  unexpected_methods: set[str]
81
83
 
82
- __slots__ = ("enabled", "generate_duplicate_query_parameters", "generation", "checks", "unexpected_methods")
84
+ __slots__ = (
85
+ "enabled",
86
+ "generate_duplicate_query_parameters",
87
+ "generation",
88
+ "checks",
89
+ "unexpected_methods",
90
+ "_is_default",
91
+ )
83
92
 
84
93
  def __init__(
85
94
  self,
@@ -95,6 +104,13 @@ class CoveragePhaseConfig(DiffBase):
95
104
  self.unexpected_methods = unexpected_methods or DEFAULT_UNEXPECTED_METHODS
96
105
  self.generation = generation or GenerationConfig()
97
106
  self.checks = checks or ChecksConfig()
107
+ self._is_default = (
108
+ enabled
109
+ and not generate_duplicate_query_parameters
110
+ and generation is None
111
+ and checks is None
112
+ and unexpected_methods is None
113
+ )
98
114
 
99
115
  @classmethod
100
116
  def from_dict(cls, data: dict[str, Any]) -> CoveragePhaseConfig:
@@ -142,7 +158,7 @@ class StatefulPhaseConfig(DiffBase):
142
158
  max_steps: int
143
159
  inference: InferenceConfig
144
160
 
145
- __slots__ = ("enabled", "generation", "checks", "max_steps", "inference")
161
+ __slots__ = ("enabled", "generation", "checks", "max_steps", "inference", "_is_default")
146
162
 
147
163
  def __init__(
148
164
  self,
@@ -158,6 +174,7 @@ class StatefulPhaseConfig(DiffBase):
158
174
  self.generation = generation or GenerationConfig()
159
175
  self.checks = checks or ChecksConfig()
160
176
  self.inference = inference or InferenceConfig()
177
+ self._is_default = enabled and generation is None and checks is None and max_steps is None and inference is None
161
178
 
162
179
  @classmethod
163
180
  def from_dict(cls, data: dict[str, Any]) -> StatefulPhaseConfig:
@@ -347,6 +347,8 @@ class ProjectConfig(DiffBase):
347
347
  for op in self.operations.operations:
348
348
  if op._filter_set.applies_to(operation=operation):
349
349
  configs.append(op.phases)
350
+ if not configs:
351
+ return self.phases
350
352
  configs.append(self.phases)
351
353
  return PhasesConfig.from_hierarchy(configs)
352
354
 
@@ -367,7 +369,10 @@ class ProjectConfig(DiffBase):
367
369
  if phase is not None:
368
370
  phases = self.phases_for(operation=operation)
369
371
  phase_config = phases.get_by_name(name=phase)
370
- configs.append(phase_config.generation)
372
+ if not phase_config._is_default:
373
+ configs.append(phase_config.generation)
374
+ if not configs:
375
+ return self.generation
371
376
  configs.append(self.generation)
372
377
  return GenerationConfig.from_hierarchy(configs)
373
378
 
@@ -388,7 +393,10 @@ class ProjectConfig(DiffBase):
388
393
  if phase is not None:
389
394
  phases = self.phases_for(operation=operation)
390
395
  phase_config = phases.get_by_name(name=phase)
391
- configs.append(phase_config.checks)
396
+ if not phase_config._is_default:
397
+ configs.append(phase_config.checks)
398
+ if not configs:
399
+ return self.checks
392
400
  configs.append(self.checks)
393
401
  return ChecksConfig.from_hierarchy(configs)
394
402
 
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, TypeVar
4
+
5
+ from schemathesis.core.parameters import ParameterLocation
6
+
7
+ T = TypeVar("T", covariant=True)
8
+
9
+
10
+ class ResponsesContainer(Protocol[T]):
11
+ def find_by_status_code(self, status_code: int) -> T | None: ... # pragma: no cover
12
+ def add(self, status_code: str, definition: dict[str, Any]) -> T: ... # pragma: no cover
13
+
14
+
15
+ class OperationParameter(Protocol):
16
+ """API parameter at a specific location (query, header, body, etc.)."""
17
+
18
+ definition: Any
19
+ """Raw parameter definition from the API spec."""
20
+
21
+ @property
22
+ def location(self) -> ParameterLocation:
23
+ """Location: "query", "header", "body", etc."""
24
+ ... # pragma: no cover
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ """Parameter name."""
29
+ ... # pragma: no cover
30
+
31
+ @property
32
+ def is_required(self) -> bool:
33
+ """True if required."""
34
+ ... # pragma: no cover
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
17
17
 
18
18
  from schemathesis.config import OutputConfig
19
19
  from schemathesis.core.compat import RefResolutionError
20
+ from schemathesis.core.jsonschema import BundleError
20
21
 
21
22
 
22
23
  SCHEMA_ERROR_SUGGESTION = "Ensure that the definition complies with the OpenAPI specification"
@@ -26,11 +27,6 @@ SERIALIZATION_NOT_POSSIBLE_MESSAGE = f"No supported serializers for media types:
26
27
  SERIALIZATION_FOR_TYPE_IS_NOT_POSSIBLE_MESSAGE = (
27
28
  f"Cannot serialize to '{{}}' (unsupported media type)\n{SERIALIZERS_SUGGESTION_MESSAGE}"
28
29
  )
29
- RECURSIVE_REFERENCE_ERROR_MESSAGE = (
30
- "Currently, Schemathesis can't generate data for this operation due to "
31
- "recursive references in the operation definition. See more information in "
32
- "this issue - https://github.com/schemathesis/schemathesis/issues/947"
33
- )
34
30
 
35
31
 
36
32
  class SchemathesisError(Exception):
@@ -50,6 +46,14 @@ class InvalidSchema(SchemathesisError):
50
46
  self.path = path
51
47
  self.method = method
52
48
 
49
+ @classmethod
50
+ def from_bundle_error(cls, error: BundleError, location: str, name: str | None = None) -> InvalidSchema:
51
+ if location == "body":
52
+ message = f"Can not generate data for {location}! {error}"
53
+ else:
54
+ message = f"Can not generate data for {location} parameter `{name}`! {error}"
55
+ return InvalidSchema(message)
56
+
53
57
  @classmethod
54
58
  def from_jsonschema_error(
55
59
  cls, error: ValidationError | JsonSchemaError, path: str | None, method: str | None, config: OutputConfig
@@ -283,6 +287,26 @@ class UnboundPrefix(SerializationError):
283
287
  super().__init__(UNBOUND_PREFIX_MESSAGE_TEMPLATE.format(prefix=prefix))
284
288
 
285
289
 
290
+ class UnresolvableReference(SchemathesisError):
291
+ """A reference cannot be resolved."""
292
+
293
+ def __init__(self, reference: str) -> None:
294
+ self.reference = reference
295
+
296
+ def __str__(self) -> str:
297
+ return f"Reference `{self.reference}` cannot be resolved"
298
+
299
+
300
+ class InfiniteRecursiveReference(SchemathesisError):
301
+ """Required recursive reference creates a cycle."""
302
+
303
+ def __init__(self, reference: str) -> None:
304
+ self.reference = reference
305
+
306
+ def __str__(self) -> str:
307
+ return f"Required reference `{self.reference}` creates a cycle"
308
+
309
+
286
310
  class SerializationNotPossible(SerializationError):
287
311
  """Not possible to serialize data to specified media type(s).
288
312
 
@@ -0,0 +1,13 @@
1
+ from .bundler import BUNDLE_STORAGE_KEY, REFERENCE_TO_BUNDLE_PREFIX, BundleError, Bundler, bundle
2
+ from .keywords import ALL_KEYWORDS
3
+ from .types import get_type
4
+
5
+ __all__ = [
6
+ "ALL_KEYWORDS",
7
+ "bundle",
8
+ "Bundler",
9
+ "BundleError",
10
+ "REFERENCE_TO_BUNDLE_PREFIX",
11
+ "BUNDLE_STORAGE_KEY",
12
+ "get_type",
13
+ ]
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from schemathesis.core.errors import InfiniteRecursiveReference
6
+ from schemathesis.core.jsonschema.references import sanitize
7
+ from schemathesis.core.jsonschema.types import JsonSchema, to_json_type_name
8
+ from schemathesis.core.transforms import deepclone
9
+
10
+ if TYPE_CHECKING:
11
+ from schemathesis.core.compat import RefResolver
12
+
13
+
14
+ BUNDLE_STORAGE_KEY = "x-bundled"
15
+ REFERENCE_TO_BUNDLE_PREFIX = f"#/{BUNDLE_STORAGE_KEY}"
16
+
17
+
18
+ class BundleError(Exception):
19
+ def __init__(self, reference: str, value: Any) -> None:
20
+ self.reference = reference
21
+ self.value = value
22
+
23
+ def __str__(self) -> str:
24
+ return f"Cannot bundle `{self.reference}`: expected JSON Schema (object or boolean), got {to_json_type_name(self.value)}"
25
+
26
+
27
+ class Bundler:
28
+ """Bundler tracks schema ids stored in a bundle."""
29
+
30
+ counter: int
31
+
32
+ __slots__ = ("counter",)
33
+
34
+ def __init__(self) -> None:
35
+ self.counter = 0
36
+
37
+ def bundle(self, schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> JsonSchema:
38
+ """Bundle a JSON Schema by embedding all references."""
39
+ # Inlining recursive reference is required (for now) for data generation, but is unsound for data validation
40
+ if not isinstance(schema, dict):
41
+ return schema
42
+
43
+ # Track visited URIs and their local definition names
44
+ visited: set[str] = set()
45
+ uri_to_def_name: dict[str, str] = {}
46
+ defs = {}
47
+
48
+ has_recursive_references = False
49
+ resolve = resolver.resolve
50
+ visit = visited.add
51
+
52
+ def get_def_name(uri: str) -> str:
53
+ """Generate or retrieve the local definition name for a URI."""
54
+ name = uri_to_def_name.get(uri)
55
+ if name is None:
56
+ self.counter += 1
57
+ name = f"schema{self.counter}"
58
+ uri_to_def_name[uri] = name
59
+ return name
60
+
61
+ def bundle_recursive(current: JsonSchema | list[JsonSchema]) -> JsonSchema | list[JsonSchema]:
62
+ """Recursively process and bundle references in the current schema."""
63
+ # Local lookup is cheaper and it matters for large schemas.
64
+ # It works because this recursive call goes to every nested value
65
+ nonlocal has_recursive_references
66
+ _bundle_recursive = bundle_recursive
67
+ if isinstance(current, dict):
68
+ reference = current.get("$ref")
69
+ if isinstance(reference, str) and not reference.startswith(REFERENCE_TO_BUNDLE_PREFIX):
70
+ resolved_uri, resolved_schema = resolve(reference)
71
+
72
+ if not isinstance(resolved_schema, (dict, bool)):
73
+ raise BundleError(reference, resolved_schema)
74
+ def_name = get_def_name(resolved_uri)
75
+
76
+ is_recursive_reference = resolved_uri in resolver._scopes_stack
77
+ has_recursive_references |= is_recursive_reference
78
+ if inline_recursive and is_recursive_reference:
79
+ # This is a recursive reference! As of Sep 2025, `hypothesis-jsonschema` does not support
80
+ # recursive references and Schemathesis has to remove them if possible.
81
+ #
82
+ # Cutting them of immediately would limit the quality of generated data, since it would have
83
+ # just a single level of recursion. Currently, the only way to generate recursive data is to
84
+ # inline definitions directly, which can lead to schema size explosion.
85
+ #
86
+ # To balance it, Schemathesis inlines one level, that avoids exponential blowup of O(B ^ L)
87
+ # in worst case, where B is branching factor (number of recursive references per schema), and
88
+ # L is the number of levels. Even quadratic growth can be unacceptable for large schemas.
89
+ #
90
+ # In the future, it **should** be handled by `hypothesis-jsonschema` instead.
91
+ cloned = deepclone(resolved_schema)
92
+ remaining_references = sanitize(cloned)
93
+ if remaining_references:
94
+ # This schema is either infinitely recursive or the sanitization logic misses it, in any
95
+ # event, we git up here
96
+ raise InfiniteRecursiveReference(reference)
97
+
98
+ result = {key: _bundle_recursive(value) for key, value in current.items() if key != "$ref"}
99
+ # Recursive references need `$ref` to be in them, which is only possible with `dict`
100
+ assert isinstance(cloned, dict)
101
+ result.update(cloned)
102
+ return result
103
+ elif resolved_uri not in visited:
104
+ # Bundle only new schemas
105
+ visit(resolved_uri)
106
+
107
+ # Recursively bundle the embedded schema too!
108
+ resolver.push_scope(resolved_uri)
109
+ try:
110
+ bundled_resolved = _bundle_recursive(resolved_schema)
111
+ finally:
112
+ resolver.pop_scope()
113
+
114
+ defs[def_name] = bundled_resolved
115
+
116
+ return {
117
+ key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
118
+ if key == "$ref"
119
+ else _bundle_recursive(value)
120
+ if isinstance(value, (dict, list))
121
+ else value
122
+ for key, value in current.items()
123
+ }
124
+ else:
125
+ # Already visited - just update $ref
126
+ return {
127
+ key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
128
+ if key == "$ref"
129
+ else _bundle_recursive(value)
130
+ if isinstance(value, (dict, list))
131
+ else value
132
+ for key, value in current.items()
133
+ }
134
+ return {
135
+ key: _bundle_recursive(value) if isinstance(value, (dict, list)) else value
136
+ for key, value in current.items()
137
+ }
138
+ elif isinstance(current, list):
139
+ return [_bundle_recursive(item) if isinstance(item, (dict, list)) else item for item in current] # type: ignore[misc]
140
+ # `isinstance` guards won't let it happen
141
+ # Otherwise is present to make type checker happy
142
+ return current # pragma: no cover
143
+
144
+ bundled = bundle_recursive(schema)
145
+
146
+ assert isinstance(bundled, dict)
147
+
148
+ # Inlining such a schema is only possible if recursive references were inlined
149
+ if (inline_recursive or not has_recursive_references) and "$ref" in bundled and len(defs) == 1:
150
+ result = {key: value for key, value in bundled.items() if key != "$ref"}
151
+ for value in defs.values():
152
+ if isinstance(value, dict):
153
+ result.update(value)
154
+ return result
155
+
156
+ if defs:
157
+ bundled[BUNDLE_STORAGE_KEY] = defs
158
+ return bundled
159
+
160
+
161
+ def bundle(schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> JsonSchema:
162
+ """Bundle a JSON Schema by embedding all references."""
163
+ return Bundler().bundle(schema, resolver, inline_recursive=inline_recursive)
@@ -1,11 +1,3 @@
1
- LOCATION_TO_CONTAINER = {
2
- "path": "path_parameters",
3
- "query": "query",
4
- "header": "headers",
5
- "cookie": "cookies",
6
- "body": "body",
7
- }
8
-
9
1
  ALL_KEYWORDS = {
10
2
  "additionalItems",
11
3
  "additionalProperties",