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
@@ -2,24 +2,22 @@ from __future__ import annotations
2
2
 
3
3
  import codecs
4
4
  import operator
5
- import os
6
5
  import pathlib
7
- import re
8
6
  from contextlib import contextmanager
9
- from functools import partial, reduce
10
- from typing import Callable, Generator, Sequence
7
+ from functools import reduce
8
+ from typing import Callable, Generator
11
9
  from urllib.parse import urlparse
12
10
 
13
11
  import click
14
12
 
15
- from schemathesis import errors, experimental
16
- from schemathesis.cli.commands.run.reports import ReportFormat
17
- from schemathesis.cli.constants import DEFAULT_WORKERS
18
- from schemathesis.core import rate_limit, string_to_boolean
13
+ from schemathesis.cli.ext.options import CsvEnumChoice
14
+ from schemathesis.config import ReportFormat, SchemathesisWarning, get_workers_count
15
+ from schemathesis.core import errors, rate_limit, string_to_boolean
19
16
  from schemathesis.core.fs import file_exists
20
- from schemathesis.core.validation import contains_unicode_surrogate_pair, has_invalid_characters, is_latin_1_encodable
17
+ from schemathesis.core.validation import has_invalid_characters, is_latin_1_encodable
18
+ from schemathesis.filters import expression_to_filter_function
21
19
  from schemathesis.generation import GenerationMode
22
- from schemathesis.generation.overrides import Override
20
+ from schemathesis.generation.metrics import MetricFunction
23
21
 
24
22
  INVALID_DERANDOMIZE_MESSAGE = (
25
23
  "`--generation-deterministic` implies no database, so passing `--generation-database` too is invalid."
@@ -35,27 +33,24 @@ INVALID_BASE_URL_MESSAGE = (
35
33
  "The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
36
34
  "Make sure it is a properly formatted URL."
37
35
  )
38
- MISSING_BASE_URL_MESSAGE = "The `--url` option is required when specifying a schema via a file."
39
36
  MISSING_REQUEST_CERT_MESSAGE = "The `--request-cert` option must be specified if `--request-cert-key` is used."
40
37
 
41
38
 
42
- def validate_schema(schema: str, base_url: str | None) -> None:
39
+ def validate_schema_location(ctx: click.core.Context, param: click.core.Parameter, location: str) -> str:
43
40
  try:
44
- netloc = urlparse(schema).netloc
41
+ netloc = urlparse(location).netloc
45
42
  if netloc:
46
- validate_url(schema)
47
- return None
43
+ validate_url(location)
44
+ return location
48
45
  except ValueError as exc:
49
46
  raise click.UsageError(INVALID_SCHEMA_MESSAGE) from exc
50
- if "\x00" in schema or not schema:
47
+ if "\x00" in location or not location:
51
48
  raise click.UsageError(INVALID_SCHEMA_MESSAGE)
52
- exists = file_exists(schema)
53
- if exists or bool(pathlib.Path(schema).suffix):
49
+ exists = file_exists(location)
50
+ if exists or bool(pathlib.Path(location).suffix):
54
51
  if not exists:
55
52
  raise click.UsageError(FILE_DOES_NOT_EXIST_MESSAGE)
56
- if base_url is None:
57
- raise click.UsageError(MISSING_BASE_URL_MESSAGE)
58
- return None
53
+ return location
59
54
  raise click.UsageError(INVALID_SCHEMA_MESSAGE)
60
55
 
61
56
 
@@ -122,82 +117,31 @@ def validate_auth(
122
117
  return None
123
118
 
124
119
 
125
- def validate_auth_overlap(auth: tuple[str, str] | None, headers: dict[str, str], override: Override) -> None:
120
+ def validate_auth_overlap(auth: tuple[str, str] | None, headers: dict[str, str]) -> None:
126
121
  auth_is_set = auth is not None
127
122
  header_is_set = "authorization" in {header.lower() for header in headers}
128
- override_is_set = "authorization" in {header.lower() for header in override.headers}
129
- if len([is_set for is_set in (auth_is_set, header_is_set, override_is_set) if is_set]) > 1:
123
+ if len([is_set for is_set in (auth_is_set, header_is_set) if is_set]) > 1:
130
124
  message = "The "
131
125
  used = []
132
126
  if auth_is_set:
133
127
  used.append("`--auth`")
134
128
  if header_is_set:
135
129
  used.append("`--header`")
136
- if override_is_set:
137
- used.append("`--set-header`")
138
130
  message += " and ".join(used)
139
131
  message += " options were both used to set the 'Authorization' header, which is not permitted."
140
132
  raise click.BadParameter(message)
141
133
 
142
134
 
143
- def _validate_and_build_multiple_options(
144
- values: tuple[str, ...], name: str, callback: Callable[[str, str], None]
145
- ) -> dict[str, str]:
146
- output = {}
147
- for raw in values:
135
+ def validate_filter_expression(
136
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
137
+ ) -> Callable | None:
138
+ if raw_value:
148
139
  try:
149
- key, value = raw.split("=", maxsplit=1)
150
- except ValueError as exc:
151
- raise click.BadParameter(f"Expected NAME=VALUE format, received {raw}.") from exc
152
- key = key.strip()
153
- if not key:
154
- raise click.BadParameter(f"{name} parameter name should not be empty.")
155
- if key in output:
156
- raise click.BadParameter(f"{name} parameter {key} is specified multiple times.")
157
- value = value.strip()
158
- callback(key, value)
159
- output[key] = value
160
- return output
161
-
162
-
163
- def validate_unique_filter(values: Sequence[str], arg_name: str) -> None:
164
- if len(values) != len(set(values)):
165
- duplicates = ",".join(sorted({value for value in values if values.count(value) > 1}))
166
- raise click.UsageError(f"Duplicate values are not allowed for `{arg_name}`: {duplicates}")
167
-
168
-
169
- def _validate_set_query(_: str, value: str) -> None:
170
- if contains_unicode_surrogate_pair(value):
171
- raise click.BadParameter("Query parameter value should not contain surrogates.")
172
-
173
-
174
- def validate_set_query(
175
- ctx: click.core.Context, param: click.core.Parameter, raw_value: tuple[str, ...]
176
- ) -> dict[str, str]:
177
- return _validate_and_build_multiple_options(raw_value, "Query", _validate_set_query)
178
-
179
-
180
- def validate_set_header(
181
- ctx: click.core.Context, param: click.core.Parameter, raw_value: tuple[str, ...]
182
- ) -> dict[str, str]:
183
- return _validate_and_build_multiple_options(raw_value, "Header", partial(_validate_header, where="Header"))
184
-
185
-
186
- def validate_set_cookie(
187
- ctx: click.core.Context, param: click.core.Parameter, raw_value: tuple[str, ...]
188
- ) -> dict[str, str]:
189
- return _validate_and_build_multiple_options(raw_value, "Cookie", partial(_validate_header, where="Cookie"))
190
-
191
-
192
- def _validate_set_path(_: str, value: str) -> None:
193
- if contains_unicode_surrogate_pair(value):
194
- raise click.BadParameter("Path parameter value should not contain surrogates.")
195
-
196
-
197
- def validate_set_path(
198
- ctx: click.core.Context, param: click.core.Parameter, raw_value: tuple[str, ...]
199
- ) -> dict[str, str]:
200
- return _validate_and_build_multiple_options(raw_value, "Path", _validate_set_path)
140
+ return expression_to_filter_function(raw_value)
141
+ except ValueError:
142
+ arg_name = param.opts[0]
143
+ raise click.UsageError(f"Invalid expression for {arg_name}: {raw_value}") from None
144
+ return None
201
145
 
202
146
 
203
147
  def _validate_header(key: str, value: str, where: str) -> None:
@@ -225,15 +169,6 @@ def validate_headers(
225
169
  return headers
226
170
 
227
171
 
228
- def validate_regex(ctx: click.core.Context, param: click.core.Parameter, raw_value: tuple[str, ...]) -> tuple[str, ...]:
229
- for value in raw_value:
230
- try:
231
- re.compile(value)
232
- except (re.error, OverflowError, RuntimeError) as exc:
233
- raise click.BadParameter(f"Invalid regex: {exc.args[0]}.") # noqa: B904
234
- return raw_value
235
-
236
-
237
172
  def validate_request_cert_key(
238
173
  ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
239
174
  ) -> str | None:
@@ -256,73 +191,26 @@ def validate_preserve_bytes(ctx: click.core.Context, param: click.core.Parameter
256
191
  return True
257
192
 
258
193
 
259
- def convert_experimental(
260
- ctx: click.core.Context, param: click.core.Parameter, value: tuple[str, ...]
261
- ) -> list[experimental.Experiment]:
262
- return [
263
- feature
264
- for feature in experimental.GLOBAL_EXPERIMENTS.available
265
- if feature.label in value or feature.is_env_var_set
266
- ]
267
-
268
-
269
- def reduce_list(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
270
- return reduce(operator.iadd, value, [])
271
-
272
-
273
- def convert_http_methods(
274
- ctx: click.core.Context, param: click.core.Parameter, value: list[str] | None
275
- ) -> set[str] | None:
276
- if value is None:
277
- return value
278
- return {item.lower() for item in value}
279
-
280
-
281
- def convert_status_codes(
282
- ctx: click.core.Context, param: click.core.Parameter, value: list[str] | None
194
+ def reduce_list(
195
+ ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]] | None
283
196
  ) -> list[str] | None:
284
197
  if not value:
285
- return value
286
-
287
- invalid = []
288
-
289
- for code in value:
290
- if len(code) != 3:
291
- invalid.append(code)
292
- continue
293
-
294
- if code[0] not in {"1", "2", "3", "4", "5"}:
295
- invalid.append(code)
296
- continue
297
-
298
- upper_code = code.upper()
198
+ return None
199
+ return reduce(operator.iadd, value, [])
299
200
 
300
- if "X" in upper_code:
301
- if (
302
- upper_code[1:] == "XX"
303
- or (upper_code[1] == "X" and upper_code[2].isdigit())
304
- or (upper_code[1].isdigit() and upper_code[2] == "X")
305
- ):
306
- continue
307
- else:
308
- invalid.append(code)
309
- continue
310
201
 
311
- if not code.isnumeric():
312
- invalid.append(code)
202
+ def convert_maximize(
203
+ ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]
204
+ ) -> list[MetricFunction]:
205
+ from schemathesis.generation.metrics import METRICS
313
206
 
314
- if invalid:
315
- raise click.UsageError(
316
- f"Invalid status code(s): {', '.join(invalid)}. "
317
- "Use valid 3-digit codes between 100 and 599, "
318
- "or wildcards (e.g., 2XX, 2X0, 20X), where X is a wildcard digit."
319
- )
320
- return value
207
+ names: list[str] = reduce(operator.iadd, value, [])
208
+ return METRICS.get_by_names(names)
321
209
 
322
210
 
323
211
  def convert_generation_mode(ctx: click.core.Context, param: click.core.Parameter, value: str) -> list[GenerationMode]:
324
212
  if value == "all":
325
- return GenerationMode.all()
213
+ return list(GenerationMode)
326
214
  return [GenerationMode(value)]
327
215
 
328
216
 
@@ -338,19 +226,21 @@ def reraise_format_error(raw_value: str) -> Generator[None, None, None]:
338
226
  raise click.BadParameter(f"Expected KEY:VALUE format, received {raw_value}.") from exc
339
227
 
340
228
 
341
- def get_workers_count() -> int:
342
- """Detect the number of available CPUs for the current process, if possible.
343
-
344
- Use ``DEFAULT_WORKERS`` if not possible to detect.
345
- """
346
- if hasattr(os, "sched_getaffinity"):
347
- # In contrast with `os.cpu_count` this call respects limits on CPU resources on some Unix systems
348
- return len(os.sched_getaffinity(0))
349
- # Number of CPUs in the system, or 1 if undetermined
350
- return os.cpu_count() or DEFAULT_WORKERS
351
-
352
-
353
229
  def convert_workers(ctx: click.core.Context, param: click.core.Parameter, value: str) -> int:
354
230
  if value == "auto":
355
231
  return get_workers_count()
356
232
  return int(value)
233
+
234
+
235
+ WARNINGS_CHOICE = CsvEnumChoice(SchemathesisWarning)
236
+
237
+
238
+ def validate_warnings(
239
+ ctx: click.core.Context, param: click.core.Parameter, value: str | None
240
+ ) -> bool | None | list[SchemathesisWarning]:
241
+ if value is None:
242
+ return None
243
+ boolean = string_to_boolean(value)
244
+ if isinstance(boolean, bool):
245
+ return boolean
246
+ return WARNINGS_CHOICE.convert(value, param, ctx) # type: ignore[return-value]
schemathesis/cli/core.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
4
  import shutil
3
5
 
@@ -9,9 +11,9 @@ def get_terminal_width() -> int:
9
11
  return shutil.get_terminal_size((80, 24)).columns
10
12
 
11
13
 
12
- def ensure_color(ctx: click.Context, no_color: bool, force_color: bool) -> None:
13
- if force_color:
14
+ def ensure_color(ctx: click.Context, color: bool | None) -> None:
15
+ if color:
14
16
  ctx.color = True
15
- elif no_color or "NO_COLOR" in os.environ:
17
+ elif color is False or "NO_COLOR" in os.environ:
16
18
  ctx.color = False
17
19
  os.environ["NO_COLOR"] = "1"
@@ -1,14 +1,16 @@
1
+ from pathlib import Path
2
+
1
3
  import click
2
4
 
3
5
  from schemathesis.core.fs import ensure_parent
4
6
 
5
7
 
6
- def open_file(file: click.utils.LazyFile) -> None:
8
+ def open_file(file: Path) -> None:
7
9
  try:
8
- ensure_parent(file.name, fail_silently=False)
10
+ ensure_parent(file, fail_silently=False)
9
11
  except OSError as exc:
10
12
  raise click.BadParameter(f"'{file.name}': {exc.strerror}") from exc
11
13
  try:
12
- file.open()
13
- except click.FileError as exc:
14
- raise click.BadParameter(exc.format_message()) from exc
14
+ file.open("w", encoding="utf-8")
15
+ except OSError as exc:
16
+ raise click.BadParameter(f"Could not open file {file.name}: {exc}") from exc
@@ -5,7 +5,6 @@ from typing import Any, NoReturn
5
5
 
6
6
  import click
7
7
 
8
- from schemathesis.core import NOT_SET, NotSet
9
8
  from schemathesis.core.registries import Registry
10
9
 
11
10
 
@@ -64,13 +63,6 @@ class CsvEnumChoice(BaseCsvChoice):
64
63
  self.fail_on_invalid_options(invalid_options, selected)
65
64
 
66
65
 
67
- class CsvListChoice(click.ParamType):
68
- def convert( # type: ignore[return]
69
- self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
70
- ) -> list[str]:
71
- return [item for item in value.split(",") if item]
72
-
73
-
74
66
  class RegistryChoice(BaseCsvChoice):
75
67
  def __init__(self, registry: Registry, with_all: bool = False) -> None:
76
68
  self.registry = registry
@@ -91,16 +83,3 @@ class RegistryChoice(BaseCsvChoice):
91
83
  if not invalid_options and selected:
92
84
  return selected
93
85
  self.fail_on_invalid_options(invalid_options, selected)
94
-
95
-
96
- class OptionalInt(click.types.IntRange):
97
- def convert( # type: ignore
98
- self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
99
- ) -> int | NotSet:
100
- if value.lower() == "none":
101
- return NOT_SET
102
- try:
103
- int(value)
104
- return super().convert(value, param, ctx)
105
- except ValueError:
106
- self.fail(f"{value} is not a valid integer or None.", param, ctx)
@@ -0,0 +1,189 @@
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, SchemathesisWarning, 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
+ "SchemathesisWarning",
51
+ ]
52
+
53
+
54
+ @dataclass(repr=False)
55
+ class SchemathesisConfig(DiffBase):
56
+ color: bool | None
57
+ suppress_health_check: list[HealthCheck]
58
+ _seed: int | None
59
+ wait_for_schema: float | int | None
60
+ max_failures: int | None
61
+ reports: ReportsConfig
62
+ output: OutputConfig
63
+ projects: ProjectsConfig
64
+
65
+ __slots__ = (
66
+ "color",
67
+ "suppress_health_check",
68
+ "_seed",
69
+ "wait_for_schema",
70
+ "max_failures",
71
+ "reports",
72
+ "output",
73
+ "projects",
74
+ )
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ color: bool | None = None,
80
+ suppress_health_check: list[HealthCheck] | None = None,
81
+ seed: int | None = None,
82
+ wait_for_schema: float | int | None = None,
83
+ max_failures: int | None = None,
84
+ reports: ReportsConfig | None = None,
85
+ output: OutputConfig | None = None,
86
+ projects: ProjectsConfig | None = None,
87
+ ):
88
+ self.color = color
89
+ self.suppress_health_check = suppress_health_check or []
90
+ self._seed = seed
91
+ self.wait_for_schema = wait_for_schema
92
+ self.max_failures = max_failures
93
+ self.reports = reports or ReportsConfig()
94
+ self.output = output or OutputConfig()
95
+ self.projects = projects or ProjectsConfig()
96
+ self.projects._set_parent(self)
97
+
98
+ @property
99
+ def seed(self) -> int:
100
+ if self._seed is None:
101
+ self._seed = Random().getrandbits(128)
102
+ return self._seed
103
+
104
+ @classmethod
105
+ def discover(cls) -> SchemathesisConfig:
106
+ """Discover the Schemathesis configuration file.
107
+
108
+ Search for 'schemathesis.toml' in the current directory and then in each parent directory,
109
+ stopping when a directory containing a '.git' folder is encountered or the filesystem root is reached.
110
+ If a config file is found, load it; otherwise, return a default configuration.
111
+ """
112
+ current_dir = os.getcwd()
113
+ config_file = None
114
+
115
+ while True:
116
+ candidate = os.path.join(current_dir, "schemathesis.toml")
117
+ if os.path.isfile(candidate):
118
+ config_file = candidate
119
+ break
120
+
121
+ # Stop searching if we've reached a git repository root
122
+ git_dir = os.path.join(current_dir, ".git")
123
+ if os.path.isdir(git_dir):
124
+ break
125
+
126
+ # Stop if we've reached the filesystem root
127
+ parent = os.path.dirname(current_dir)
128
+ if parent == current_dir:
129
+ break
130
+ current_dir = parent
131
+
132
+ if config_file:
133
+ return cls.from_path(config_file)
134
+ return cls()
135
+
136
+ def update(
137
+ self,
138
+ *,
139
+ color: bool | None = None,
140
+ suppress_health_check: list[HealthCheck] | None = None,
141
+ seed: int | None = None,
142
+ wait_for_schema: float | int | None = None,
143
+ max_failures: int | None,
144
+ ) -> None:
145
+ """Set top-level configuration options."""
146
+ if color is not None:
147
+ self.color = color
148
+ if suppress_health_check is not None:
149
+ self.suppress_health_check = suppress_health_check
150
+ if seed is not None:
151
+ self._seed = seed
152
+ if wait_for_schema is not None:
153
+ self.wait_for_schema = wait_for_schema
154
+ if max_failures is not None:
155
+ self.max_failures = max_failures
156
+
157
+ @classmethod
158
+ def from_path(cls, path: PathLike | str) -> SchemathesisConfig:
159
+ """Load configuration from a file path."""
160
+ with open(path, encoding="utf-8") as fd:
161
+ return cls.from_str(fd.read())
162
+
163
+ @classmethod
164
+ def from_str(cls, data: str) -> SchemathesisConfig:
165
+ """Parse configuration from a string."""
166
+ parsed = tomli.loads(data)
167
+ return cls.from_dict(parsed)
168
+
169
+ @classmethod
170
+ def from_dict(cls, data: dict) -> SchemathesisConfig:
171
+ """Create a config instance from a dictionary."""
172
+ from jsonschema.exceptions import ValidationError
173
+
174
+ from schemathesis.config._validator import CONFIG_VALIDATOR
175
+
176
+ try:
177
+ CONFIG_VALIDATOR.validate(data)
178
+ except ValidationError as exc:
179
+ raise ConfigError.from_validation_error(exc) from None
180
+ return cls(
181
+ color=data.get("color"),
182
+ suppress_health_check=[HealthCheck(name) for name in data.get("suppress-health-check", [])],
183
+ seed=data.get("seed"),
184
+ wait_for_schema=data.get("wait-for-schema"),
185
+ max_failures=data.get("max-failures"),
186
+ reports=ReportsConfig.from_dict(data.get("reports", {})),
187
+ output=OutputConfig.from_dict(data.get("output", {})),
188
+ projects=ProjectsConfig.from_dict(data),
189
+ )
@@ -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.")