schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a12__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 (111) hide show
  1. schemathesis/__init__.py +29 -30
  2. schemathesis/auths.py +65 -24
  3. schemathesis/checks.py +73 -39
  4. schemathesis/cli/commands/__init__.py +51 -3
  5. schemathesis/cli/commands/data.py +10 -0
  6. schemathesis/cli/commands/run/__init__.py +163 -274
  7. schemathesis/cli/commands/run/context.py +8 -4
  8. schemathesis/cli/commands/run/events.py +11 -1
  9. schemathesis/cli/commands/run/executor.py +70 -78
  10. schemathesis/cli/commands/run/filters.py +15 -165
  11. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  12. schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
  13. schemathesis/cli/commands/run/handlers/output.py +195 -121
  14. schemathesis/cli/commands/run/loaders.py +35 -50
  15. schemathesis/cli/commands/run/validation.py +52 -162
  16. schemathesis/cli/core.py +5 -3
  17. schemathesis/cli/ext/fs.py +7 -5
  18. schemathesis/cli/ext/options.py +0 -21
  19. schemathesis/config/__init__.py +189 -0
  20. schemathesis/config/_auth.py +51 -0
  21. schemathesis/config/_checks.py +268 -0
  22. schemathesis/config/_diff_base.py +99 -0
  23. schemathesis/config/_env.py +21 -0
  24. schemathesis/config/_error.py +156 -0
  25. schemathesis/config/_generation.py +149 -0
  26. schemathesis/config/_health_check.py +24 -0
  27. schemathesis/config/_operations.py +327 -0
  28. schemathesis/config/_output.py +171 -0
  29. schemathesis/config/_parameters.py +19 -0
  30. schemathesis/config/_phases.py +187 -0
  31. schemathesis/config/_projects.py +523 -0
  32. schemathesis/config/_rate_limit.py +17 -0
  33. schemathesis/config/_report.py +120 -0
  34. schemathesis/config/_validator.py +9 -0
  35. schemathesis/config/_warnings.py +25 -0
  36. schemathesis/config/schema.json +885 -0
  37. schemathesis/core/__init__.py +2 -0
  38. schemathesis/core/compat.py +16 -9
  39. schemathesis/core/errors.py +24 -4
  40. schemathesis/core/failures.py +6 -7
  41. schemathesis/core/hooks.py +20 -0
  42. schemathesis/core/output/__init__.py +14 -37
  43. schemathesis/core/output/sanitization.py +3 -146
  44. schemathesis/core/transport.py +36 -1
  45. schemathesis/core/validation.py +16 -0
  46. schemathesis/engine/__init__.py +2 -4
  47. schemathesis/engine/context.py +42 -43
  48. schemathesis/engine/core.py +7 -5
  49. schemathesis/engine/errors.py +60 -1
  50. schemathesis/engine/events.py +10 -2
  51. schemathesis/engine/phases/__init__.py +10 -0
  52. schemathesis/engine/phases/probes.py +11 -8
  53. schemathesis/engine/phases/stateful/__init__.py +2 -1
  54. schemathesis/engine/phases/stateful/_executor.py +104 -46
  55. schemathesis/engine/phases/stateful/context.py +2 -2
  56. schemathesis/engine/phases/unit/__init__.py +23 -15
  57. schemathesis/engine/phases/unit/_executor.py +110 -21
  58. schemathesis/engine/phases/unit/_pool.py +1 -1
  59. schemathesis/errors.py +2 -0
  60. schemathesis/filters.py +2 -3
  61. schemathesis/generation/__init__.py +5 -33
  62. schemathesis/generation/case.py +6 -3
  63. schemathesis/generation/coverage.py +154 -124
  64. schemathesis/generation/hypothesis/builder.py +70 -20
  65. schemathesis/generation/meta.py +3 -3
  66. schemathesis/generation/metrics.py +93 -0
  67. schemathesis/generation/modes.py +0 -8
  68. schemathesis/generation/overrides.py +37 -1
  69. schemathesis/generation/stateful/__init__.py +4 -0
  70. schemathesis/generation/stateful/state_machine.py +9 -1
  71. schemathesis/graphql/loaders.py +159 -16
  72. schemathesis/hooks.py +62 -35
  73. schemathesis/openapi/checks.py +12 -8
  74. schemathesis/openapi/generation/filters.py +10 -8
  75. schemathesis/openapi/loaders.py +142 -17
  76. schemathesis/pytest/lazy.py +2 -5
  77. schemathesis/pytest/loaders.py +24 -0
  78. schemathesis/pytest/plugin.py +33 -2
  79. schemathesis/schemas.py +21 -66
  80. schemathesis/specs/graphql/scalars.py +37 -3
  81. schemathesis/specs/graphql/schemas.py +23 -18
  82. schemathesis/specs/openapi/_hypothesis.py +26 -28
  83. schemathesis/specs/openapi/checks.py +37 -36
  84. schemathesis/specs/openapi/examples.py +4 -3
  85. schemathesis/specs/openapi/formats.py +32 -5
  86. schemathesis/specs/openapi/media_types.py +44 -1
  87. schemathesis/specs/openapi/negative/__init__.py +2 -2
  88. schemathesis/specs/openapi/patterns.py +46 -16
  89. schemathesis/specs/openapi/references.py +2 -3
  90. schemathesis/specs/openapi/schemas.py +19 -22
  91. schemathesis/specs/openapi/stateful/__init__.py +12 -6
  92. schemathesis/transport/__init__.py +54 -16
  93. schemathesis/transport/prepare.py +38 -13
  94. schemathesis/transport/requests.py +12 -9
  95. schemathesis/transport/wsgi.py +11 -12
  96. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +50 -97
  97. schemathesis-4.0.0a12.dist-info/RECORD +164 -0
  98. schemathesis/cli/commands/run/checks.py +0 -79
  99. schemathesis/cli/commands/run/hypothesis.py +0 -78
  100. schemathesis/cli/commands/run/reports.py +0 -72
  101. schemathesis/cli/hooks.py +0 -36
  102. schemathesis/contrib/__init__.py +0 -9
  103. schemathesis/contrib/openapi/__init__.py +0 -9
  104. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  105. schemathesis/engine/config.py +0 -59
  106. schemathesis/experimental/__init__.py +0 -72
  107. schemathesis/generation/targets.py +0 -69
  108. schemathesis-4.0.0a10.dist-info/RECORD +0 -153
  109. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/WHEEL +0 -0
  110. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
  111. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, ClassVar, Sequence
5
+
6
+ from schemathesis.config._diff_base import DiffBase
7
+ from schemathesis.config._error import ConfigError
8
+
9
+ if TYPE_CHECKING:
10
+ from typing_extensions import Self
11
+
12
+ NOT_A_SERVER_ERROR_EXPECTED_STATUSES = ["2xx", "3xx", "4xx"]
13
+ NEGATIVE_DATA_REJECTION_EXPECTED_STATUSES = ["400", "401", "403", "404", "406", "422", "428", "5xx"]
14
+ POSITIVE_DATA_ACCEPTANCE_EXPECTED_STATUSES = ["2xx", "401", "403", "404", "5xx"]
15
+ MISSING_REQUIRED_HEADER_EXPECTED_STATUSES = ["406"]
16
+
17
+
18
+ def validate_status_codes(value: Sequence[str] | None) -> Sequence[str] | None:
19
+ if not value:
20
+ return value
21
+
22
+ invalid = []
23
+
24
+ for code in value:
25
+ if len(code) != 3:
26
+ invalid.append(code)
27
+ continue
28
+
29
+ if code[0] not in {"1", "2", "3", "4", "5"}:
30
+ invalid.append(code)
31
+ continue
32
+
33
+ upper_code = code.upper()
34
+
35
+ if "X" in upper_code:
36
+ if (
37
+ upper_code[1:] == "XX"
38
+ or (upper_code[1] == "X" and upper_code[2].isdigit())
39
+ or (upper_code[1].isdigit() and upper_code[2] == "X")
40
+ ):
41
+ continue
42
+ else:
43
+ invalid.append(code)
44
+ continue
45
+
46
+ if not code.isnumeric():
47
+ invalid.append(code)
48
+
49
+ if invalid:
50
+ raise ConfigError(
51
+ f"Invalid status code(s): {', '.join(invalid)}. "
52
+ "Use valid 3-digit codes between 100 and 599, "
53
+ "or wildcards (e.g., 2XX, 2X0, 20X), where X is a wildcard digit."
54
+ )
55
+ return value
56
+
57
+
58
+ @dataclass(repr=False)
59
+ class SimpleCheckConfig(DiffBase):
60
+ enabled: bool
61
+
62
+ __slots__ = ("enabled",)
63
+
64
+ def __init__(self, *, enabled: bool = True) -> None:
65
+ self.enabled = enabled
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: dict[str, Any]) -> SimpleCheckConfig:
69
+ return cls(enabled=data.get("enabled", True))
70
+
71
+
72
+ @dataclass(repr=False)
73
+ class MaxResponseTimeConfig(DiffBase):
74
+ enabled: bool
75
+ limit: float | None
76
+
77
+ __slots__ = ("enabled", "limit")
78
+
79
+ def __init__(self, *, limit: float | None = None) -> None:
80
+ self.enabled = limit is not None
81
+ self.limit = limit
82
+
83
+
84
+ @dataclass(repr=False)
85
+ class CheckConfig(DiffBase):
86
+ enabled: bool
87
+ expected_statuses: list[str]
88
+ _DEFAULT_EXPECTED_STATUSES: ClassVar[list[str]]
89
+
90
+ __slots__ = ("enabled", "expected_statuses")
91
+
92
+ def __init__(self, *, enabled: bool = True, expected_statuses: Sequence[str | int] | None = None) -> None:
93
+ self.enabled = enabled
94
+ if expected_statuses is not None:
95
+ statuses = [str(status) for status in expected_statuses]
96
+ validate_status_codes(statuses)
97
+ self.expected_statuses = statuses
98
+ else:
99
+ self.expected_statuses = self._DEFAULT_EXPECTED_STATUSES
100
+
101
+ @classmethod
102
+ def from_dict(cls, data: dict[str, Any]) -> Self:
103
+ enabled = data.get("enabled", True)
104
+ return cls(
105
+ enabled=enabled,
106
+ expected_statuses=data.get("expected-statuses", cls._DEFAULT_EXPECTED_STATUSES),
107
+ )
108
+
109
+
110
+ class NotAServerErrorConfig(CheckConfig):
111
+ _DEFAULT_EXPECTED_STATUSES = NOT_A_SERVER_ERROR_EXPECTED_STATUSES
112
+
113
+
114
+ class PositiveDataAcceptanceConfig(CheckConfig):
115
+ _DEFAULT_EXPECTED_STATUSES = POSITIVE_DATA_ACCEPTANCE_EXPECTED_STATUSES
116
+
117
+
118
+ class NegativeDataRejectionConfig(CheckConfig):
119
+ _DEFAULT_EXPECTED_STATUSES = NEGATIVE_DATA_REJECTION_EXPECTED_STATUSES
120
+
121
+
122
+ class MissingRequiredHeaderConfig(CheckConfig):
123
+ _DEFAULT_EXPECTED_STATUSES = MISSING_REQUIRED_HEADER_EXPECTED_STATUSES
124
+
125
+
126
+ @dataclass(repr=False)
127
+ class ChecksConfig(DiffBase):
128
+ not_a_server_error: NotAServerErrorConfig
129
+ status_code_conformance: SimpleCheckConfig
130
+ content_type_conformance: SimpleCheckConfig
131
+ response_schema_conformance: SimpleCheckConfig
132
+ response_headers_conformance: SimpleCheckConfig
133
+ positive_data_acceptance: PositiveDataAcceptanceConfig
134
+ negative_data_rejection: NegativeDataRejectionConfig
135
+ use_after_free: SimpleCheckConfig
136
+ ensure_resource_availability: SimpleCheckConfig
137
+ missing_required_header: MissingRequiredHeaderConfig
138
+ ignored_auth: SimpleCheckConfig
139
+ unsupported_method: SimpleCheckConfig
140
+ max_response_time: MaxResponseTimeConfig
141
+ _unknown: dict[str, SimpleCheckConfig]
142
+
143
+ __slots__ = (
144
+ "not_a_server_error",
145
+ "status_code_conformance",
146
+ "content_type_conformance",
147
+ "response_schema_conformance",
148
+ "response_headers_conformance",
149
+ "positive_data_acceptance",
150
+ "negative_data_rejection",
151
+ "use_after_free",
152
+ "ensure_resource_availability",
153
+ "missing_required_header",
154
+ "ignored_auth",
155
+ "unsupported_method",
156
+ "max_response_time",
157
+ "_unknown",
158
+ )
159
+
160
+ def __init__(
161
+ self,
162
+ *,
163
+ not_a_server_error: NotAServerErrorConfig | None = None,
164
+ status_code_conformance: SimpleCheckConfig | None = None,
165
+ content_type_conformance: SimpleCheckConfig | None = None,
166
+ response_schema_conformance: SimpleCheckConfig | None = None,
167
+ response_headers_conformance: SimpleCheckConfig | None = None,
168
+ positive_data_acceptance: PositiveDataAcceptanceConfig | None = None,
169
+ negative_data_rejection: NegativeDataRejectionConfig | None = None,
170
+ use_after_free: SimpleCheckConfig | None = None,
171
+ ensure_resource_availability: SimpleCheckConfig | None = None,
172
+ missing_required_header: MissingRequiredHeaderConfig | None = None,
173
+ ignored_auth: SimpleCheckConfig | None = None,
174
+ unsupported_method: SimpleCheckConfig | None = None,
175
+ max_response_time: MaxResponseTimeConfig | None = None,
176
+ ) -> None:
177
+ self.not_a_server_error = not_a_server_error or NotAServerErrorConfig()
178
+ self.status_code_conformance = status_code_conformance or SimpleCheckConfig()
179
+ self.content_type_conformance = content_type_conformance or SimpleCheckConfig()
180
+ self.response_schema_conformance = response_schema_conformance or SimpleCheckConfig()
181
+ self.response_headers_conformance = response_headers_conformance or SimpleCheckConfig()
182
+ self.positive_data_acceptance = positive_data_acceptance or PositiveDataAcceptanceConfig()
183
+ self.negative_data_rejection = negative_data_rejection or NegativeDataRejectionConfig()
184
+ self.use_after_free = use_after_free or SimpleCheckConfig()
185
+ self.ensure_resource_availability = ensure_resource_availability or SimpleCheckConfig()
186
+ self.missing_required_header = missing_required_header or MissingRequiredHeaderConfig()
187
+ self.ignored_auth = ignored_auth or SimpleCheckConfig()
188
+ self.unsupported_method = unsupported_method or SimpleCheckConfig()
189
+ self.max_response_time = max_response_time or MaxResponseTimeConfig()
190
+ self._unknown = {}
191
+
192
+ @classmethod
193
+ def from_dict(cls, data: dict[str, Any]) -> ChecksConfig:
194
+ # Use the outer "enabled" value as default for all checks.
195
+ default_enabled = data.get("enabled", None)
196
+
197
+ def merge(sub: dict[str, Any]) -> dict[str, Any]:
198
+ # Merge the default enabled flag with the sub-dict; the sub-dict takes precedence.
199
+ if default_enabled is not None:
200
+ return {"enabled": default_enabled, **sub}
201
+ return sub
202
+
203
+ return cls(
204
+ not_a_server_error=NotAServerErrorConfig.from_dict(
205
+ merge(data.get("not_a_server_error", {})),
206
+ ),
207
+ status_code_conformance=SimpleCheckConfig.from_dict(merge(data.get("status_code_conformance", {}))),
208
+ content_type_conformance=SimpleCheckConfig.from_dict(merge(data.get("content_type_conformance", {}))),
209
+ response_schema_conformance=SimpleCheckConfig.from_dict(merge(data.get("response_schema_conformance", {}))),
210
+ response_headers_conformance=SimpleCheckConfig.from_dict(
211
+ merge(data.get("response_headers_conformance", {}))
212
+ ),
213
+ positive_data_acceptance=PositiveDataAcceptanceConfig.from_dict(
214
+ merge(data.get("positive_data_acceptance", {})),
215
+ ),
216
+ negative_data_rejection=NegativeDataRejectionConfig.from_dict(
217
+ merge(data.get("negative_data_rejection", {})),
218
+ ),
219
+ use_after_free=SimpleCheckConfig.from_dict(merge(data.get("use_after_free", {}))),
220
+ ensure_resource_availability=SimpleCheckConfig.from_dict(
221
+ merge(data.get("ensure_resource_availability", {}))
222
+ ),
223
+ missing_required_header=MissingRequiredHeaderConfig.from_dict(
224
+ merge(data.get("missing_required_header", {})),
225
+ ),
226
+ ignored_auth=SimpleCheckConfig.from_dict(merge(data.get("ignored_auth", {}))),
227
+ unsupported_method=SimpleCheckConfig.from_dict(merge(data.get("unsupported_method", {}))),
228
+ max_response_time=MaxResponseTimeConfig(limit=data.get("max_response_time")),
229
+ )
230
+
231
+ def get_by_name(self, *, name: str) -> CheckConfig | SimpleCheckConfig | MaxResponseTimeConfig:
232
+ try:
233
+ return getattr(self, name)
234
+ except AttributeError:
235
+ return self._unknown.setdefault(name, SimpleCheckConfig())
236
+
237
+ def update(
238
+ self,
239
+ *,
240
+ included_check_names: list[str] | None = None,
241
+ excluded_check_names: list[str] | None = None,
242
+ max_response_time: float | None = None,
243
+ ) -> None:
244
+ known_names = {name for name in self.__slots__ if not name.startswith("_")}
245
+ for name in known_names:
246
+ # Check in explicitly excluded or not in explicitly included
247
+ if name in (excluded_check_names or []) or (
248
+ included_check_names is not None
249
+ and "all" not in included_check_names
250
+ and name not in included_check_names
251
+ ):
252
+ config = self.get_by_name(name=name)
253
+ config.enabled = False
254
+ elif included_check_names is not None and name in included_check_names:
255
+ config = self.get_by_name(name=name)
256
+ config.enabled = True
257
+
258
+ if max_response_time is not None:
259
+ self.max_response_time.enabled = True
260
+ self.max_response_time.limit = max_response_time
261
+
262
+ for name in included_check_names or []:
263
+ if name not in known_names and name != "all":
264
+ self._unknown[name] = SimpleCheckConfig(enabled=True)
265
+
266
+ for name in excluded_check_names or []:
267
+ if name not in known_names and name != "all":
268
+ self._unknown[name] = SimpleCheckConfig(enabled=False)
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, fields, is_dataclass
4
+ from typing import TypeVar
5
+
6
+ T = TypeVar("T", bound="DiffBase")
7
+
8
+
9
+ @dataclass
10
+ class DiffBase:
11
+ def __repr__(self) -> str:
12
+ """Show only the fields that differ from the default."""
13
+ assert is_dataclass(self)
14
+ default = self.__class__()
15
+ diffs = []
16
+ for field in fields(self):
17
+ name = field.name
18
+ if name.startswith("_") and name not in ("_seed", "_filter_set"):
19
+ continue
20
+ current_value = getattr(self, name)
21
+ default_value = getattr(default, name)
22
+ if name == "_seed":
23
+ name = "seed"
24
+ if name == "_filter_set":
25
+ name = "filter_set"
26
+ if name == "rate_limit" and current_value is not None:
27
+ assert hasattr(self, "_rate_limit")
28
+ current_value = self._rate_limit
29
+ if self._has_diff(current_value, default_value):
30
+ diffs.append(f"{name}={self._diff_repr(current_value, default_value)}")
31
+ return f"{self.__class__.__name__}({', '.join(diffs)})"
32
+
33
+ def _has_diff(self, value: object, default: object) -> bool:
34
+ if is_dataclass(value):
35
+ return repr(value) != repr(default)
36
+ if isinstance(value, list) and isinstance(default, list):
37
+ if len(value) != len(default):
38
+ return True
39
+ return any(self._has_diff(v, d) for v, d in zip(value, default))
40
+ if isinstance(value, dict) and isinstance(default, dict):
41
+ if set(value.keys()) != set(default.keys()):
42
+ return True
43
+ return any(self._has_diff(value[k], default[k]) for k in value)
44
+ return value != default
45
+
46
+ def _diff_repr(self, value: object, default: object) -> str:
47
+ if is_dataclass(value):
48
+ # If the nested object is a dataclass, recursively show its diff.
49
+ return repr(value)
50
+ if isinstance(value, list) and isinstance(default, list):
51
+ diff_items = []
52
+ # Compare items pairwise.
53
+ for v, d in zip(value, default):
54
+ if self._has_diff(v, d):
55
+ diff_items.append(self._diff_repr(v, d))
56
+ # Include any extra items in value.
57
+ if len(value) > len(default):
58
+ diff_items.extend(_repr(item) for item in value[len(default) :])
59
+ return f"[{', '.join(_repr(item) for item in value)}]"
60
+ if isinstance(value, dict) and isinstance(default, dict):
61
+ diff_items = []
62
+ for k, v in value.items():
63
+ d = default.get(k)
64
+ if self._has_diff(v, d):
65
+ diff_items.append(f"{k!r}: {self._diff_repr(v, d)}")
66
+ return f"{{{', '.join(diff_items)}}}"
67
+ return repr(value)
68
+
69
+ @classmethod
70
+ def from_hierarchy(cls, configs: list[T]) -> T:
71
+ # This config will accumulate "merged" config options
72
+ output = cls()
73
+ for option in cls.__slots__: # type: ignore
74
+ if option.startswith("_"):
75
+ continue
76
+ default = getattr(output, option)
77
+ if is_dataclass(default):
78
+ # Sub-configs require merging of nested config options
79
+ sub_configs = [getattr(config, option) for config in configs]
80
+ merged = type(default).from_hierarchy(sub_configs) # type: ignore[union-attr]
81
+ setattr(output, option, merged)
82
+ else:
83
+ # Primitive config options can be compared directly and do not
84
+ # require merging of nested options
85
+ for config in configs:
86
+ current = getattr(config, option)
87
+ if current != default:
88
+ setattr(output, option, current)
89
+ # As we go from the highest priority to the lowest one,
90
+ # we can just stop on the first non-default value
91
+ break
92
+ return output # type: ignore
93
+
94
+
95
+ def _repr(item: object) -> str:
96
+ if callable(item) and hasattr(item, "__name__"):
97
+ return f"<function {item.__name__}>"
98
+
99
+ return repr(item)
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from string import Template
5
+ from typing import Any
6
+
7
+ from schemathesis.config._error import ConfigError
8
+
9
+
10
+ def resolve(value: Any) -> Any:
11
+ """Resolve environment variables using string templates."""
12
+ if value is None:
13
+ return None
14
+ if not isinstance(value, str):
15
+ return value
16
+ try:
17
+ return Template(value).substitute(os.environ)
18
+ except ValueError:
19
+ raise ConfigError(f"Invalid placeholder in string: `{value}`") from None
20
+ except KeyError:
21
+ raise ConfigError(f"Missing environment variable: `{value}`") from None
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ from typing import TYPE_CHECKING
5
+
6
+ from schemathesis.core.errors import SchemathesisError
7
+
8
+ if TYPE_CHECKING:
9
+ from jsonschema import ValidationError
10
+
11
+
12
+ class ConfigError(SchemathesisError):
13
+ """Invalid configuration."""
14
+
15
+ @classmethod
16
+ def from_validation_error(cls, error: ValidationError) -> ConfigError:
17
+ message = error.message
18
+ if error.validator == "enum":
19
+ message = _format_enum_error(error)
20
+ elif error.validator == "minimum":
21
+ message = _format_minimum_error(error)
22
+ elif error.validator == "required":
23
+ message = _format_required_error(error)
24
+ elif error.validator == "type":
25
+ message = _format_type_error(error)
26
+ elif error.validator == "additionalProperties":
27
+ message = _format_additional_properties_error(error)
28
+ elif error.validator == "anyOf":
29
+ message = _format_anyof_error(error)
30
+ return cls(message)
31
+
32
+
33
+ def _format_minimum_error(error: ValidationError) -> str:
34
+ assert isinstance(error.validator_value, (int, float))
35
+ section = path_to_section_name(list(error.path)[:-1] if error.path else [])
36
+ assert error.path
37
+
38
+ prop_name = error.path[-1]
39
+ min_value = error.validator_value
40
+ actual_value = error.instance
41
+
42
+ return (
43
+ f"Error in {section} section:\n Value too low:\n\n"
44
+ f" - '{prop_name}' → Must be at least {min_value}, but got {actual_value}."
45
+ )
46
+
47
+
48
+ def _format_required_error(error: ValidationError) -> str:
49
+ assert isinstance(error.validator_value, list)
50
+ missing_keys = sorted(set(error.validator_value) - set(error.instance))
51
+
52
+ section = path_to_section_name(list(error.path))
53
+
54
+ details = "\n".join(f" - '{key}'" for key in missing_keys)
55
+ return f"Error in {section} section:\n Missing required properties:\n\n{details}\n\n"
56
+
57
+
58
+ def _format_enum_error(error: ValidationError) -> str:
59
+ assert isinstance(error.validator_value, list)
60
+ valid_values = sorted(error.validator_value)
61
+
62
+ path = list(error.path)
63
+
64
+ if path and isinstance(path[-1], int):
65
+ idx = path[-1]
66
+ prop_name = path[-2]
67
+ section_path = path[:-2]
68
+ description = f"Item #{idx} in the '{prop_name}' array"
69
+ else:
70
+ prop_name = path[-1] if path else "value"
71
+ section_path = path[:-1]
72
+ description = f"'{prop_name}'"
73
+
74
+ suggestion = ""
75
+ if isinstance(error.instance, str) and all(isinstance(v, str) for v in valid_values):
76
+ match = _find_closest_match(error.instance, valid_values)
77
+ if match:
78
+ suggestion = f" Did you mean '{match}'?"
79
+
80
+ section = path_to_section_name(section_path)
81
+ valid_values_str = ", ".join(repr(v) for v in valid_values)
82
+ return (
83
+ f"Error in {section} section:\n Invalid value:\n\n"
84
+ f" - {description} → '{error.instance}' is not a valid value.{suggestion}\n\n"
85
+ f"Valid values are: {valid_values_str}."
86
+ )
87
+
88
+
89
+ def _format_type_error(error: ValidationError) -> str:
90
+ expected = error.validator_value
91
+ assert isinstance(expected, (str, list))
92
+ section = path_to_section_name(list(error.path)[:-1] if error.path else [])
93
+ assert error.path
94
+
95
+ type_phrases = {
96
+ "object": "an object",
97
+ "array": "an array",
98
+ "number": "a number",
99
+ "boolean": "a boolean",
100
+ "string": "a string",
101
+ "integer": "an integer",
102
+ "null": "null",
103
+ }
104
+ message = f"Error in {section} section:\n Type error:\n\n - '{error.path[-1]}' → Must be "
105
+
106
+ if isinstance(expected, list):
107
+ message += f"one of: {' or '.join(expected)}"
108
+ else:
109
+ message += type_phrases[expected]
110
+ actual = type(error.instance).__name__
111
+ message += f", but got {actual}: {error.instance}"
112
+ return message
113
+
114
+
115
+ def _format_additional_properties_error(error: ValidationError) -> str:
116
+ valid = list(error.schema.get("properties", {}))
117
+ unknown = sorted(set(error.instance) - set(valid))
118
+ valid_list = ", ".join(f"'{prop}'" for prop in valid)
119
+ section = path_to_section_name(list(error.path))
120
+
121
+ details = []
122
+ for prop in unknown:
123
+ match = _find_closest_match(prop, valid)
124
+ if match:
125
+ details.append(f"- '{prop}' → Did you mean '{match}'?")
126
+ else:
127
+ details.append(f"- '{prop}'")
128
+
129
+ return (
130
+ f"Error in {section} section:\n Unknown properties:\n\n"
131
+ + "\n".join(f" {detail}" for detail in details)
132
+ + f"\n\nValid properties for {section} are: {valid_list}."
133
+ )
134
+
135
+
136
+ def _format_anyof_error(error: ValidationError) -> str:
137
+ if list(error.schema_path) == ["properties", "operations", "items", "anyOf"]:
138
+ section = path_to_section_name(list(error.path))
139
+ return (
140
+ f"Error in {section} section:\n At least one filter is required when defining [[operations]].\n\n"
141
+ "Please specify at least one include or exclude filter property (e.g., include-path, exclude-tag, etc.)."
142
+ )
143
+ return error.message
144
+
145
+
146
+ def path_to_section_name(path: list[int | str]) -> str:
147
+ """Convert a JSON path to a TOML-like section name."""
148
+ if not path:
149
+ return "root"
150
+
151
+ return f"[{'.'.join(str(p) for p in path)}]"
152
+
153
+
154
+ def _find_closest_match(value: str, variants: list[str]) -> str | None:
155
+ matches = difflib.get_close_matches(value, variants, n=1, cutoff=0.6)
156
+ return matches[0] if matches else None
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from schemathesis.config._diff_base import DiffBase
7
+ from schemathesis.generation.modes import GenerationMode
8
+
9
+ if TYPE_CHECKING:
10
+ from schemathesis.generation.metrics import MetricFunction
11
+
12
+
13
+ @dataclass(repr=False)
14
+ class GenerationConfig(DiffBase):
15
+ modes: list[GenerationMode]
16
+ max_examples: int | None
17
+ no_shrink: bool
18
+ deterministic: bool
19
+ # Allow generating `\x00` bytes in strings
20
+ allow_x00: bool
21
+ # Generate strings using the given codec
22
+ codec: str | None
23
+ maximize: list[MetricFunction]
24
+ # Whether to generate security parameters
25
+ with_security_parameters: bool
26
+ # Allowing using `null` for optional arguments in GraphQL queries
27
+ graphql_allow_null: bool
28
+ database: str | None
29
+ unique_inputs: bool
30
+ exclude_header_characters: str | None
31
+
32
+ __slots__ = (
33
+ "modes",
34
+ "max_examples",
35
+ "no_shrink",
36
+ "deterministic",
37
+ "allow_x00",
38
+ "codec",
39
+ "maximize",
40
+ "with_security_parameters",
41
+ "graphql_allow_null",
42
+ "database",
43
+ "unique_inputs",
44
+ "exclude_header_characters",
45
+ )
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ modes: list[GenerationMode] | None = None,
51
+ max_examples: int | None = None,
52
+ no_shrink: bool = False,
53
+ deterministic: bool = False,
54
+ allow_x00: bool = True,
55
+ codec: str | None = "utf-8",
56
+ maximize: list[MetricFunction] | None = None,
57
+ with_security_parameters: bool = True,
58
+ graphql_allow_null: bool = True,
59
+ database: str | None = None,
60
+ unique_inputs: bool = False,
61
+ exclude_header_characters: str | None = None,
62
+ ) -> None:
63
+ from schemathesis.generation import GenerationMode
64
+
65
+ self.modes = modes or list(GenerationMode)
66
+ self.max_examples = max_examples
67
+ self.no_shrink = no_shrink
68
+ self.deterministic = deterministic
69
+ self.allow_x00 = allow_x00
70
+ self.codec = codec
71
+ self.maximize = maximize or []
72
+ self.with_security_parameters = with_security_parameters
73
+ self.graphql_allow_null = graphql_allow_null
74
+ self.database = database
75
+ self.unique_inputs = unique_inputs
76
+ self.exclude_header_characters = exclude_header_characters
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: dict[str, Any]) -> GenerationConfig:
80
+ mode_raw = data.get("mode")
81
+ if mode_raw == "all":
82
+ modes = list(GenerationMode)
83
+ elif mode_raw is not None:
84
+ modes = [GenerationMode(mode_raw)]
85
+ else:
86
+ modes = None
87
+ maximize = _get_maximize(data.get("maximize"))
88
+ return cls(
89
+ modes=modes,
90
+ max_examples=data.get("max-examples"),
91
+ no_shrink=data.get("no-shrink", False),
92
+ deterministic=data.get("deterministic", False),
93
+ allow_x00=data.get("allow-x00", True),
94
+ codec=data.get("codec", "utf-8"),
95
+ maximize=maximize,
96
+ with_security_parameters=data.get("with-security-parameters", True),
97
+ graphql_allow_null=data.get("graphql-allow-null", True),
98
+ database=data.get("database"),
99
+ unique_inputs=data.get("unique-inputs", False),
100
+ exclude_header_characters=data.get("exclude-header-characters"),
101
+ )
102
+
103
+ def update(
104
+ self,
105
+ *,
106
+ modes: list[GenerationMode] | None = None,
107
+ max_examples: int | None = None,
108
+ no_shrink: bool = False,
109
+ deterministic: bool | None = None,
110
+ allow_x00: bool = True,
111
+ codec: str | None = None,
112
+ maximize: list[MetricFunction] | None = None,
113
+ with_security_parameters: bool | None = None,
114
+ graphql_allow_null: bool = True,
115
+ database: str | None = None,
116
+ unique_inputs: bool = False,
117
+ exclude_header_characters: str | None = None,
118
+ ) -> None:
119
+ if modes is not None:
120
+ self.modes = modes
121
+ if max_examples is not None:
122
+ self.max_examples = max_examples
123
+ self.no_shrink = no_shrink
124
+ self.deterministic = deterministic or False
125
+ self.allow_x00 = allow_x00
126
+ if codec is not None:
127
+ self.codec = codec
128
+ if maximize is not None:
129
+ self.maximize = maximize
130
+ if with_security_parameters is not None:
131
+ self.with_security_parameters = with_security_parameters
132
+ self.graphql_allow_null = graphql_allow_null
133
+ if database is not None:
134
+ self.database = database
135
+ self.unique_inputs = unique_inputs
136
+ if exclude_header_characters is not None:
137
+ self.exclude_header_characters = exclude_header_characters
138
+
139
+
140
+ def _get_maximize(value: Any) -> list[MetricFunction]:
141
+ from schemathesis.generation.metrics import METRICS
142
+
143
+ if isinstance(value, list):
144
+ metrics = value
145
+ elif isinstance(value, str):
146
+ metrics = [value]
147
+ else:
148
+ metrics = []
149
+ return METRICS.get_by_names(metrics)