schemathesis 4.0.0a9__py3-none-any.whl → 4.0.0a11__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 (93) hide show
  1. schemathesis/__init__.py +3 -7
  2. schemathesis/checks.py +17 -7
  3. schemathesis/cli/commands/__init__.py +51 -3
  4. schemathesis/cli/commands/data.py +10 -0
  5. schemathesis/cli/commands/run/__init__.py +147 -260
  6. schemathesis/cli/commands/run/context.py +2 -3
  7. schemathesis/cli/commands/run/events.py +4 -0
  8. schemathesis/cli/commands/run/executor.py +60 -73
  9. schemathesis/cli/commands/run/filters.py +15 -165
  10. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  11. schemathesis/cli/commands/run/handlers/junitxml.py +6 -5
  12. schemathesis/cli/commands/run/handlers/output.py +26 -47
  13. schemathesis/cli/commands/run/loaders.py +35 -50
  14. schemathesis/cli/commands/run/validation.py +36 -161
  15. schemathesis/cli/core.py +5 -3
  16. schemathesis/cli/ext/fs.py +7 -5
  17. schemathesis/cli/ext/options.py +0 -21
  18. schemathesis/config/__init__.py +188 -0
  19. schemathesis/config/_auth.py +51 -0
  20. schemathesis/config/_checks.py +268 -0
  21. schemathesis/config/_diff_base.py +99 -0
  22. schemathesis/config/_env.py +21 -0
  23. schemathesis/config/_error.py +156 -0
  24. schemathesis/config/_generation.py +150 -0
  25. schemathesis/config/_health_check.py +24 -0
  26. schemathesis/config/_operations.py +313 -0
  27. schemathesis/config/_output.py +171 -0
  28. schemathesis/config/_parameters.py +19 -0
  29. schemathesis/config/_phases.py +151 -0
  30. schemathesis/config/_projects.py +495 -0
  31. schemathesis/config/_rate_limit.py +17 -0
  32. schemathesis/config/_report.py +116 -0
  33. schemathesis/config/_validator.py +9 -0
  34. schemathesis/config/schema.json +837 -0
  35. schemathesis/core/__init__.py +3 -0
  36. schemathesis/core/compat.py +16 -9
  37. schemathesis/core/errors.py +19 -2
  38. schemathesis/core/failures.py +6 -7
  39. schemathesis/core/hooks.py +20 -0
  40. schemathesis/core/output/__init__.py +14 -37
  41. schemathesis/core/output/sanitization.py +3 -146
  42. schemathesis/core/validation.py +16 -0
  43. schemathesis/engine/__init__.py +2 -4
  44. schemathesis/engine/context.py +41 -43
  45. schemathesis/engine/core.py +7 -5
  46. schemathesis/engine/phases/__init__.py +10 -0
  47. schemathesis/engine/phases/probes.py +8 -8
  48. schemathesis/engine/phases/stateful/_executor.py +68 -43
  49. schemathesis/engine/phases/unit/__init__.py +23 -15
  50. schemathesis/engine/phases/unit/_executor.py +77 -17
  51. schemathesis/engine/phases/unit/_pool.py +1 -1
  52. schemathesis/errors.py +2 -0
  53. schemathesis/filters.py +2 -3
  54. schemathesis/generation/__init__.py +6 -31
  55. schemathesis/generation/case.py +5 -3
  56. schemathesis/generation/coverage.py +174 -134
  57. schemathesis/generation/hypothesis/__init__.py +7 -1
  58. schemathesis/generation/hypothesis/builder.py +40 -14
  59. schemathesis/generation/meta.py +3 -3
  60. schemathesis/generation/overrides.py +37 -1
  61. schemathesis/generation/stateful/state_machine.py +8 -1
  62. schemathesis/graphql/loaders.py +21 -12
  63. schemathesis/openapi/checks.py +12 -8
  64. schemathesis/openapi/generation/filters.py +10 -8
  65. schemathesis/openapi/loaders.py +22 -13
  66. schemathesis/pytest/lazy.py +2 -5
  67. schemathesis/pytest/plugin.py +11 -2
  68. schemathesis/schemas.py +13 -61
  69. schemathesis/specs/graphql/schemas.py +11 -15
  70. schemathesis/specs/openapi/_hypothesis.py +12 -8
  71. schemathesis/specs/openapi/checks.py +16 -18
  72. schemathesis/specs/openapi/examples.py +4 -3
  73. schemathesis/specs/openapi/formats.py +2 -2
  74. schemathesis/specs/openapi/negative/__init__.py +2 -2
  75. schemathesis/specs/openapi/patterns.py +46 -16
  76. schemathesis/specs/openapi/references.py +2 -3
  77. schemathesis/specs/openapi/schemas.py +11 -20
  78. schemathesis/specs/openapi/stateful/__init__.py +10 -5
  79. schemathesis/transport/prepare.py +7 -6
  80. schemathesis/transport/requests.py +3 -1
  81. schemathesis/transport/wsgi.py +3 -4
  82. {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/METADATA +7 -8
  83. schemathesis-4.0.0a11.dist-info/RECORD +166 -0
  84. schemathesis/cli/commands/run/checks.py +0 -79
  85. schemathesis/cli/commands/run/hypothesis.py +0 -78
  86. schemathesis/cli/commands/run/reports.py +0 -72
  87. schemathesis/cli/hooks.py +0 -36
  88. schemathesis/engine/config.py +0 -59
  89. schemathesis/experimental/__init__.py +0 -72
  90. schemathesis-4.0.0a9.dist-info/RECORD +0 -153
  91. {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/WHEEL +0 -0
  92. {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/entry_points.txt +0 -0
  93. {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from os import PathLike
6
+ from random import Random
7
+
8
+ import tomli
9
+
10
+ from schemathesis.config._checks import (
11
+ CheckConfig,
12
+ ChecksConfig,
13
+ NotAServerErrorConfig,
14
+ PositiveDataAcceptanceConfig,
15
+ SimpleCheckConfig,
16
+ )
17
+ from schemathesis.config._diff_base import DiffBase
18
+ from schemathesis.config._error import ConfigError
19
+ from schemathesis.config._generation import GenerationConfig
20
+ from schemathesis.config._health_check import HealthCheck
21
+ from schemathesis.config._output import OutputConfig, SanitizationConfig, TruncationConfig
22
+ from schemathesis.config._phases import CoveragePhaseConfig, PhaseConfig, PhasesConfig, StatefulPhaseConfig
23
+ from schemathesis.config._projects import ProjectConfig, ProjectsConfig, get_workers_count
24
+ from schemathesis.config._report import DEFAULT_REPORT_DIRECTORY, ReportConfig, ReportFormat, ReportsConfig
25
+
26
+ __all__ = [
27
+ "SchemathesisConfig",
28
+ "ConfigError",
29
+ "HealthCheck",
30
+ "ReportConfig",
31
+ "ReportsConfig",
32
+ "ReportFormat",
33
+ "DEFAULT_REPORT_DIRECTORY",
34
+ "GenerationConfig",
35
+ "OutputConfig",
36
+ "SanitizationConfig",
37
+ "TruncationConfig",
38
+ "ChecksConfig",
39
+ "CheckConfig",
40
+ "NotAServerErrorConfig",
41
+ "PositiveDataAcceptanceConfig",
42
+ "SimpleCheckConfig",
43
+ "PhaseConfig",
44
+ "PhasesConfig",
45
+ "CoveragePhaseConfig",
46
+ "StatefulPhaseConfig",
47
+ "ProjectsConfig",
48
+ "ProjectConfig",
49
+ "get_workers_count",
50
+ ]
51
+
52
+
53
+ @dataclass(repr=False)
54
+ class SchemathesisConfig(DiffBase):
55
+ color: bool | None
56
+ suppress_health_check: list[HealthCheck]
57
+ _seed: int | None
58
+ wait_for_schema: float | int | None
59
+ max_failures: int | None
60
+ reports: ReportsConfig
61
+ output: OutputConfig
62
+ projects: ProjectsConfig
63
+
64
+ __slots__ = (
65
+ "color",
66
+ "suppress_health_check",
67
+ "_seed",
68
+ "wait_for_schema",
69
+ "max_failures",
70
+ "reports",
71
+ "output",
72
+ "projects",
73
+ )
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ color: bool | None = None,
79
+ suppress_health_check: list[HealthCheck] | None = None,
80
+ seed: int | None = None,
81
+ wait_for_schema: float | int | None = None,
82
+ max_failures: int | None = None,
83
+ reports: ReportsConfig | None = None,
84
+ output: OutputConfig | None = None,
85
+ projects: ProjectsConfig | None = None,
86
+ ):
87
+ self.color = color
88
+ self.suppress_health_check = suppress_health_check or []
89
+ self._seed = seed
90
+ self.wait_for_schema = wait_for_schema
91
+ self.max_failures = max_failures
92
+ self.reports = reports or ReportsConfig()
93
+ self.output = output or OutputConfig()
94
+ self.projects = projects or ProjectsConfig()
95
+ self.projects._set_parent(self)
96
+
97
+ @property
98
+ def seed(self) -> int:
99
+ if self._seed is None:
100
+ self._seed = Random().getrandbits(128)
101
+ return self._seed
102
+
103
+ @classmethod
104
+ def discover(cls) -> SchemathesisConfig:
105
+ """Discover the Schemathesis configuration file.
106
+
107
+ Search for 'schemathesis.toml' in the current directory and then in each parent directory,
108
+ stopping when a directory containing a '.git' folder is encountered or the filesystem root is reached.
109
+ If a config file is found, load it; otherwise, return a default configuration.
110
+ """
111
+ current_dir = os.getcwd()
112
+ config_file = None
113
+
114
+ while True:
115
+ candidate = os.path.join(current_dir, "schemathesis.toml")
116
+ if os.path.isfile(candidate):
117
+ config_file = candidate
118
+ break
119
+
120
+ # Stop searching if we've reached a git repository root
121
+ git_dir = os.path.join(current_dir, ".git")
122
+ if os.path.isdir(git_dir):
123
+ break
124
+
125
+ # Stop if we've reached the filesystem root
126
+ parent = os.path.dirname(current_dir)
127
+ if parent == current_dir:
128
+ break
129
+ current_dir = parent
130
+
131
+ if config_file:
132
+ return cls.from_path(config_file)
133
+ return cls()
134
+
135
+ def update(
136
+ self,
137
+ *,
138
+ color: bool | None = None,
139
+ suppress_health_check: list[HealthCheck] | None = None,
140
+ seed: int | None = None,
141
+ wait_for_schema: float | int | None = None,
142
+ max_failures: int | None,
143
+ ) -> None:
144
+ """Set top-level configuration options."""
145
+ if color is not None:
146
+ self.color = color
147
+ if suppress_health_check is not None:
148
+ self.suppress_health_check = suppress_health_check
149
+ if seed is not None:
150
+ self._seed = seed
151
+ if wait_for_schema is not None:
152
+ self.wait_for_schema = wait_for_schema
153
+ if max_failures is not None:
154
+ self.max_failures = max_failures
155
+
156
+ @classmethod
157
+ def from_path(cls, path: PathLike | str) -> SchemathesisConfig:
158
+ """Load configuration from a file path."""
159
+ with open(path, encoding="utf-8") as fd:
160
+ return cls.from_str(fd.read())
161
+
162
+ @classmethod
163
+ def from_str(cls, data: str) -> SchemathesisConfig:
164
+ """Parse configuration from a string."""
165
+ parsed = tomli.loads(data)
166
+ return cls.from_dict(parsed)
167
+
168
+ @classmethod
169
+ def from_dict(cls, data: dict) -> SchemathesisConfig:
170
+ """Create a config instance from a dictionary."""
171
+ from jsonschema.exceptions import ValidationError
172
+
173
+ from schemathesis.config._validator import CONFIG_VALIDATOR
174
+
175
+ try:
176
+ CONFIG_VALIDATOR.validate(data)
177
+ except ValidationError as exc:
178
+ raise ConfigError.from_validation_error(exc) from None
179
+ return cls(
180
+ color=data.get("color"),
181
+ suppress_health_check=[HealthCheck(name) for name in data.get("suppress-health-check", [])],
182
+ seed=data.get("seed"),
183
+ wait_for_schema=data.get("wait-for-schema"),
184
+ max_failures=data.get("max-failures"),
185
+ reports=ReportsConfig.from_dict(data.get("reports", {})),
186
+ output=OutputConfig.from_dict(data.get("output", {})),
187
+ projects=ProjectsConfig.from_dict(data),
188
+ )
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from schemathesis.config._diff_base import DiffBase
7
+ from schemathesis.config._env import resolve
8
+ from schemathesis.config._error import ConfigError
9
+ from schemathesis.core.validation import is_latin_1_encodable
10
+
11
+
12
+ @dataclass(repr=False)
13
+ class AuthConfig(DiffBase):
14
+ basic: tuple[str, str] | None
15
+
16
+ __slots__ = ("basic",)
17
+
18
+ def __init__(
19
+ self,
20
+ *,
21
+ basic: dict[str, str] | None = None,
22
+ ) -> None:
23
+ if basic is not None:
24
+ assert "username" in basic
25
+ username = resolve(basic["username"])
26
+ assert "password" in basic
27
+ password = resolve(basic["password"])
28
+ _validate_basic(username, password)
29
+ self.basic = (username, password)
30
+ else:
31
+ self.basic = None
32
+
33
+ def update(self, *, basic: tuple[str, str] | None = None) -> None:
34
+ if basic is not None:
35
+ _validate_basic(*basic)
36
+ self.basic = basic
37
+
38
+ @classmethod
39
+ def from_dict(cls, data: dict[str, Any]) -> AuthConfig:
40
+ return cls(basic=data.get("basic"))
41
+
42
+ @property
43
+ def is_defined(self) -> bool:
44
+ return self.basic is not None
45
+
46
+
47
+ def _validate_basic(username: str, password: str) -> None:
48
+ if not is_latin_1_encodable(username):
49
+ raise ConfigError("Username should be latin-1 encodable.")
50
+ if not is_latin_1_encodable(password):
51
+ raise ConfigError("Password should be latin-1 encodable.")
@@ -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