schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 (251) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1219
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +748 -82
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -0,0 +1,118 @@
1
+ """Automatic schema loading.
2
+
3
+ This module handles the automatic detection and loading of API schemas,
4
+ supporting both GraphQL and OpenAPI specifications.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import warnings
11
+ from json import JSONDecodeError
12
+ from typing import TYPE_CHECKING, Any, Callable
13
+
14
+ from schemathesis import graphql, openapi
15
+ from schemathesis.config import ProjectConfig
16
+ from schemathesis.core.errors import LoaderError, LoaderErrorKind
17
+ from schemathesis.core.fs import file_exists
18
+
19
+ if TYPE_CHECKING:
20
+ from schemathesis.schemas import BaseSchema
21
+
22
+ Loader = Callable[["ProjectConfig"], "BaseSchema"]
23
+
24
+
25
+ def load_schema(location: str, config: ProjectConfig) -> BaseSchema:
26
+ """Load API schema automatically based on the provided configuration."""
27
+ if is_probably_graphql(location):
28
+ # Try GraphQL first, then fallback to Open API
29
+ return _try_load_schema(location, config, graphql, openapi)
30
+ # Try Open API first, then fallback to GraphQL
31
+ return _try_load_schema(location, config, openapi, graphql)
32
+
33
+
34
+ def should_try_more(exc: LoaderError) -> bool:
35
+ """Determine if alternative schema loading should be attempted."""
36
+ import requests
37
+ from yaml.reader import ReaderError
38
+
39
+ if (isinstance(exc.__cause__, ReaderError) and "characters are not allowed" in str(exc.__cause__)) or (
40
+ isinstance(exc.__cause__, JSONDecodeError)
41
+ and ('"swagger"' in exc.__cause__.doc or '"openapi"' in exc.__cause__.doc)
42
+ ):
43
+ return False
44
+
45
+ # We should not try other loaders for cases when we can't even establish connection
46
+ return not isinstance(exc.__cause__, requests.exceptions.ConnectionError) and exc.kind not in (
47
+ LoaderErrorKind.OPEN_API_INVALID_SCHEMA,
48
+ LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION,
49
+ LoaderErrorKind.OPEN_API_UNSUPPORTED_VERSION,
50
+ )
51
+
52
+
53
+ def detect_loader(location: str, module: Any) -> Callable:
54
+ """Detect API schema loader."""
55
+ if file_exists(location):
56
+ return module.from_path
57
+ return module.from_url
58
+
59
+
60
+ def _try_load_schema(location: str, config: ProjectConfig, first_module: Any, second_module: Any) -> BaseSchema:
61
+ """Try to load schema with fallback option."""
62
+ from urllib3.exceptions import InsecureRequestWarning
63
+
64
+ with warnings.catch_warnings():
65
+ warnings.simplefilter("ignore", InsecureRequestWarning)
66
+ try:
67
+ return _load_schema(location, config, first_module)
68
+ except LoaderError as exc:
69
+ # If this was the OpenAPI loader on an explicit OpenAPI file, don't fallback
70
+ if first_module is openapi and is_openapi_file(location):
71
+ raise exc
72
+ if should_try_more(exc):
73
+ try:
74
+ return _load_schema(location, config, second_module)
75
+ except Exception as second_exc:
76
+ if is_specific_exception(second_exc):
77
+ raise second_exc
78
+ # Re-raise the original error
79
+ raise exc
80
+
81
+
82
+ def _load_schema(location: str, config: ProjectConfig, module: Any) -> BaseSchema:
83
+ """Unified schema loader for both GraphQL and OpenAPI."""
84
+ loader = detect_loader(location, module)
85
+
86
+ kwargs: dict = {}
87
+ if loader is module.from_url:
88
+ if config.wait_for_schema is not None:
89
+ kwargs["wait_for_schema"] = config.wait_for_schema
90
+ kwargs["verify"] = config.tls_verify
91
+ request_cert = config.request_cert_for()
92
+ if request_cert:
93
+ kwargs["cert"] = request_cert
94
+ auth = config.auth_for()
95
+ if auth is not None:
96
+ kwargs["auth"] = auth
97
+
98
+ return loader(location, config=config._parent, **kwargs)
99
+
100
+
101
+ def is_specific_exception(exc: Exception) -> bool:
102
+ """Determine if alternative schema loading should be attempted."""
103
+ return (
104
+ isinstance(exc, LoaderError)
105
+ and exc.kind == LoaderErrorKind.GRAPHQL_INVALID_SCHEMA
106
+ # In some cases it is not clear that the schema is even supposed to be GraphQL, e.g. an empty input
107
+ and "Syntax Error: Unexpected <EOF>." not in exc.extras
108
+ )
109
+
110
+
111
+ def is_probably_graphql(location: str) -> bool:
112
+ """Detect whether it is likely that the given location is a GraphQL endpoint."""
113
+ return location.endswith(("/graphql", "/graphql/", ".graphql", ".gql"))
114
+
115
+
116
+ def is_openapi_file(location: str) -> bool:
117
+ name = os.path.basename(location).lower()
118
+ return any(name == f"{base}{ext}" for base in ("openapi", "swagger") for ext in (".json", ".yaml", ".yml"))
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ import codecs
4
+ import operator
5
+ import pathlib
6
+ from contextlib import contextmanager
7
+ from functools import reduce
8
+ from typing import Callable, Generator
9
+ from urllib.parse import urlparse
10
+
11
+ import click
12
+
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
16
+ from schemathesis.core.fs import file_exists
17
+ from schemathesis.core.validation import has_invalid_characters, is_latin_1_encodable
18
+ from schemathesis.filters import expression_to_filter_function
19
+ from schemathesis.generation import GenerationMode
20
+ from schemathesis.generation.metrics import MetricFunction
21
+
22
+ INVALID_DERANDOMIZE_MESSAGE = (
23
+ "`--generation-deterministic` implies no database, so passing `--generation-database` too is invalid."
24
+ )
25
+ INVALID_REPORT_USAGE = (
26
+ "Can't use `--report-preserve-bytes` without enabling cassette formats. "
27
+ "Enable VCR or HAR format with `--report=vcr`, `--report-vcr-path`, "
28
+ "`--report=har`, or `--report-har-path`"
29
+ )
30
+ INVALID_SCHEMA_MESSAGE = "Invalid SCHEMA, must be a valid URL or file path."
31
+ FILE_DOES_NOT_EXIST_MESSAGE = "The specified file does not exist. Please provide a valid path to an existing file."
32
+ INVALID_BASE_URL_MESSAGE = (
33
+ "The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
34
+ "Make sure it is a properly formatted URL."
35
+ )
36
+ MISSING_REQUEST_CERT_MESSAGE = "The `--request-cert` option must be specified if `--request-cert-key` is used."
37
+
38
+
39
+ def validate_schema_location(ctx: click.core.Context, param: click.core.Parameter, location: str) -> str:
40
+ try:
41
+ netloc = urlparse(location).netloc
42
+ if netloc:
43
+ validate_url(location)
44
+ return location
45
+ except ValueError as exc:
46
+ raise click.UsageError(INVALID_SCHEMA_MESSAGE) from exc
47
+ if "\x00" in location or not location:
48
+ raise click.UsageError(INVALID_SCHEMA_MESSAGE)
49
+ exists = file_exists(location)
50
+ if exists or bool(pathlib.Path(location).suffix):
51
+ if not exists:
52
+ raise click.UsageError(FILE_DOES_NOT_EXIST_MESSAGE)
53
+ return location
54
+ raise click.UsageError(INVALID_SCHEMA_MESSAGE)
55
+
56
+
57
+ def validate_url(value: str) -> None:
58
+ from requests import PreparedRequest, RequestException
59
+
60
+ try:
61
+ PreparedRequest().prepare_url(value, {})
62
+ except RequestException as exc:
63
+ raise click.UsageError(INVALID_SCHEMA_MESSAGE) from exc
64
+
65
+
66
+ def validate_base_url(ctx: click.core.Context, param: click.core.Parameter, raw_value: str) -> str:
67
+ try:
68
+ netloc = urlparse(raw_value).netloc
69
+ except ValueError as exc:
70
+ raise click.UsageError(INVALID_BASE_URL_MESSAGE) from exc
71
+ if raw_value and not netloc:
72
+ raise click.UsageError(INVALID_BASE_URL_MESSAGE)
73
+ return raw_value
74
+
75
+
76
+ def validate_generation_codec(
77
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
78
+ ) -> str | None:
79
+ if raw_value is None:
80
+ return raw_value
81
+ try:
82
+ codecs.getencoder(raw_value)
83
+ except LookupError as exc:
84
+ raise click.UsageError(f"Codec `{raw_value}` is unknown") from exc
85
+ return raw_value
86
+
87
+
88
+ def validate_rate_limit(ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None) -> str | None:
89
+ if raw_value is None:
90
+ return raw_value
91
+ try:
92
+ rate_limit.parse_units(raw_value)
93
+ return raw_value
94
+ except errors.IncorrectUsage as exc:
95
+ raise click.UsageError(exc.args[0]) from exc
96
+
97
+
98
+ def validate_hypothesis_database(
99
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
100
+ ) -> str | None:
101
+ if raw_value is None:
102
+ return raw_value
103
+ if ctx.params.get("generation_deterministic"):
104
+ raise click.UsageError(INVALID_DERANDOMIZE_MESSAGE)
105
+ return raw_value
106
+
107
+
108
+ def validate_auth(
109
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
110
+ ) -> tuple[str, str] | None:
111
+ if raw_value is not None:
112
+ with reraise_format_error(raw_value):
113
+ user, password = tuple(raw_value.split(":"))
114
+ if not user:
115
+ raise click.BadParameter("Username should not be empty.")
116
+ if not is_latin_1_encodable(user):
117
+ raise click.BadParameter("Username should be latin-1 encodable.")
118
+ if not is_latin_1_encodable(password):
119
+ raise click.BadParameter("Password should be latin-1 encodable.")
120
+ return user, password
121
+ return None
122
+
123
+
124
+ def validate_auth_overlap(auth: tuple[str, str] | None, headers: dict[str, str]) -> None:
125
+ auth_is_set = auth is not None
126
+ header_is_set = "authorization" in {header.lower() for header in headers}
127
+ if len([is_set for is_set in (auth_is_set, header_is_set) if is_set]) > 1:
128
+ message = "The "
129
+ used = []
130
+ if auth_is_set:
131
+ used.append("`--auth`")
132
+ if header_is_set:
133
+ used.append("`--header`")
134
+ message += " and ".join(used)
135
+ message += " options were both used to set the 'Authorization' header, which is not permitted."
136
+ raise click.BadParameter(message)
137
+
138
+
139
+ def validate_filter_expression(
140
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
141
+ ) -> Callable | None:
142
+ if raw_value:
143
+ try:
144
+ return expression_to_filter_function(raw_value)
145
+ except ValueError:
146
+ arg_name = param.opts[0]
147
+ raise click.UsageError(f"Invalid expression for {arg_name}: {raw_value}") from None
148
+ return None
149
+
150
+
151
+ def _validate_header(key: str, value: str, where: str) -> None:
152
+ if not key:
153
+ raise click.BadParameter(f"{where} name should not be empty.")
154
+ if not is_latin_1_encodable(key):
155
+ raise click.BadParameter(f"{where} name should be latin-1 encodable.")
156
+ if not is_latin_1_encodable(value):
157
+ raise click.BadParameter(f"{where} value should be latin-1 encodable.")
158
+ if has_invalid_characters(key, value):
159
+ raise click.BadParameter(f"Invalid return character or leading space in {where.lower()}.")
160
+
161
+
162
+ def validate_headers(
163
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: tuple[str, ...]
164
+ ) -> dict[str, str]:
165
+ headers = {}
166
+ for header in raw_value:
167
+ with reraise_format_error(header):
168
+ key, value = header.split(":", maxsplit=1)
169
+ value = value.lstrip()
170
+ key = key.strip()
171
+ _validate_header(key, value, where="Header")
172
+ headers[key] = value
173
+ return headers
174
+
175
+
176
+ def validate_request_cert_key(
177
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
178
+ ) -> str | None:
179
+ if raw_value is not None and "request_cert" not in ctx.params:
180
+ raise click.UsageError(MISSING_REQUEST_CERT_MESSAGE)
181
+ return raw_value
182
+
183
+
184
+ def validate_preserve_bytes(ctx: click.core.Context, param: click.core.Parameter, raw_value: bool) -> bool:
185
+ if not raw_value:
186
+ return False
187
+
188
+ report_formats = ctx.params.get("report_formats", []) or []
189
+ vcr_enabled = ReportFormat.VCR in report_formats or ctx.params.get("report_vcr_path")
190
+ har_enabled = ReportFormat.HAR in report_formats or ctx.params.get("report_har_path")
191
+
192
+ if not (vcr_enabled or har_enabled):
193
+ raise click.UsageError(INVALID_REPORT_USAGE)
194
+
195
+ return True
196
+
197
+
198
+ def reduce_list(
199
+ ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]] | None
200
+ ) -> list[str] | None:
201
+ if not value:
202
+ return None
203
+ return reduce(operator.iadd, value, [])
204
+
205
+
206
+ def convert_maximize(
207
+ ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]
208
+ ) -> list[MetricFunction]:
209
+ from schemathesis.generation.metrics import METRICS
210
+
211
+ names: list[str] = reduce(operator.iadd, value, [])
212
+ return METRICS.get_by_names(names)
213
+
214
+
215
+ def convert_generation_mode(ctx: click.core.Context, param: click.core.Parameter, value: str) -> list[GenerationMode]:
216
+ if value == "all":
217
+ return list(GenerationMode)
218
+ return [GenerationMode(value)]
219
+
220
+
221
+ def convert_boolean_string(
222
+ ctx: click.core.Context, param: click.core.Parameter, value: str | None
223
+ ) -> str | bool | None:
224
+ if value is None:
225
+ return value
226
+ return string_to_boolean(value)
227
+
228
+
229
+ @contextmanager
230
+ def reraise_format_error(raw_value: str) -> Generator[None, None, None]:
231
+ try:
232
+ yield
233
+ except ValueError as exc:
234
+ raise click.BadParameter(f"Expected KEY:VALUE format, received {raw_value}.") from exc
235
+
236
+
237
+ def convert_workers(ctx: click.core.Context, param: click.core.Parameter, value: str | None) -> int | None:
238
+ if value is None:
239
+ return value
240
+ if value == "auto":
241
+ return get_workers_count()
242
+ return int(value)
243
+
244
+
245
+ WARNINGS_CHOICE = CsvEnumChoice(SchemathesisWarning)
246
+
247
+
248
+ def validate_warnings(
249
+ ctx: click.core.Context, param: click.core.Parameter, value: str | None
250
+ ) -> bool | None | list[SchemathesisWarning]:
251
+ if value is None:
252
+ return None
253
+ boolean = string_to_boolean(value)
254
+ if isinstance(boolean, bool):
255
+ return boolean
256
+ return WARNINGS_CHOICE.convert(value, param, ctx) # type: ignore[return-value]
@@ -1,3 +1,8 @@
1
1
  MIN_WORKERS = 1
2
2
  DEFAULT_WORKERS = MIN_WORKERS
3
3
  MAX_WORKERS = 64
4
+ ISSUE_TRACKER_URL = (
5
+ "https://github.com/schemathesis/schemathesis/issues/new?"
6
+ "labels=Status%3A%20Needs%20Triage%2C+Type%3A+Bug&template=bug_report.md&title=%5BBUG%5D"
7
+ )
8
+ EXTENSIONS_DOCUMENTATION_URL = "https://schemathesis.readthedocs.io/en/stable/guides/extending/"
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+
6
+ import click
7
+
8
+
9
+ def get_terminal_width() -> int:
10
+ # Some CI/CD providers (e.g. CircleCI) return a (0, 0) terminal size so provide a default
11
+ return shutil.get_terminal_size((80, 24)).columns
12
+
13
+
14
+ def ensure_color(ctx: click.Context, color: bool | None) -> None:
15
+ if color:
16
+ ctx.color = True
17
+ elif color is False or "NO_COLOR" in os.environ:
18
+ ctx.color = False
19
+ os.environ["NO_COLOR"] = "1"
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from schemathesis.core.fs import ensure_parent
6
+
7
+
8
+ def open_file(file: Path) -> None:
9
+ try:
10
+ ensure_parent(file, fail_silently=False)
11
+ except OSError as exc:
12
+ raise click.BadParameter(f"'{file.name}': {exc.strerror}") from exc
13
+ try:
14
+ file.open("w", encoding="utf-8")
15
+ except (OSError, ValueError) as exc:
16
+ raise click.BadParameter(f"Could not open file {file.name}: {exc}") from exc
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import textwrap
6
+ from typing import Any, Callable, Optional
7
+
8
+ import click
9
+
10
+ GROUPS: dict[str, OptionGroup] = {}
11
+
12
+
13
+ def should_use_color(ctx: click.Context) -> bool:
14
+ """Determine whether to use colored output in help text.
15
+
16
+ Priority (highest to lowest):
17
+ 1. ctx.color (if explicitly set via callbacks)
18
+ 2. --no-color flag (from command line)
19
+ 3. --force-color flag (from command line)
20
+ 4. NO_COLOR environment variable
21
+ 5. TTY detection (colorize only if stdout is a TTY)
22
+ 6. Default (False for non-TTY environments)
23
+ """
24
+ color_setting = getattr(ctx, "color", None)
25
+ if color_setting is not None:
26
+ # Explicit setting via --no-color or --force-color takes precedence
27
+ return bool(color_setting)
28
+ if "--no-color" in sys.argv:
29
+ # Check command line for --no-color flag (handles any order with -h)
30
+ return False
31
+ if "--force-color" in sys.argv:
32
+ # Check command line for --force-color flag (handles any order with -h)
33
+ return True
34
+ if "NO_COLOR" in os.environ:
35
+ # Respect NO_COLOR environment variable (https://no-color.org/)
36
+ return False
37
+ # Default based on TTY detection (matches Click's auto-detection)
38
+ # In test environments (CliRunner), stdout is not a TTY, so colors are disabled
39
+ return sys.stdout.isatty() if hasattr(sys.stdout, "isatty") else False
40
+
41
+
42
+ class OptionGroup:
43
+ __slots__ = ("order", "name", "description", "options")
44
+
45
+ def __init__(
46
+ self,
47
+ name: str,
48
+ *,
49
+ order: int | None = None,
50
+ description: str | None = None,
51
+ ):
52
+ self.name = name
53
+ self.description = description
54
+ self.order = order if order is not None else len(GROUPS) * 100
55
+ self.options: list[tuple[str, str]] = []
56
+
57
+
58
+ OPTION_COLOR = "cyan"
59
+ # Use default terminal color for better readability
60
+ HELP_COLOR: Optional[str] = None
61
+ SECTION_COLOR = "green"
62
+
63
+
64
+ def _colorize_filtering_description(description: str, style_fn: Callable[..., str]) -> str:
65
+ replacements = [
66
+ ("--include-TYPE VALUE", OPTION_COLOR),
67
+ ("Match operations with exact VALUE", HELP_COLOR),
68
+ ("--include-TYPE-regex PATTERN", OPTION_COLOR),
69
+ ("Match operations using regular expression", HELP_COLOR),
70
+ ("--exclude-TYPE VALUE", OPTION_COLOR),
71
+ ("Exclude operations with exact VALUE", HELP_COLOR),
72
+ ("--exclude-TYPE-regex PATTERN", OPTION_COLOR),
73
+ ("Exclude operations using regular expression", HELP_COLOR),
74
+ ]
75
+ for token, color in replacements:
76
+ description = description.replace(token, style_fn(token, fg=color))
77
+ return description
78
+
79
+
80
+ class CommandWithGroupedOptions(click.Command):
81
+ def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
82
+ # Collect options into groups or ungrouped list
83
+ for group in GROUPS.values():
84
+ group.options = []
85
+
86
+ use_color = should_use_color(ctx)
87
+
88
+ def style(text: str, **kwargs: Any) -> str:
89
+ # Only apply styling if colors are enabled
90
+ if use_color:
91
+ return click.style(text, **kwargs)
92
+ return text
93
+
94
+ for param in self.get_params(ctx):
95
+ rv = param.get_help_record(ctx)
96
+ if rv is not None:
97
+ option_repr, message = rv
98
+ if isinstance(param.type, click.Choice):
99
+ message += (
100
+ getattr(param.type, "choices_repr", None)
101
+ or f" [possible values: {', '.join(param.type.choices)}]"
102
+ )
103
+
104
+ styled_option = style(option_repr, fg=OPTION_COLOR, bold=True)
105
+ styled_message = style(message, fg=HELP_COLOR)
106
+
107
+ if isinstance(param, GroupedOption) and param.group is not None:
108
+ option_group = GROUPS.get(param.group)
109
+ if option_group is not None:
110
+ option_group.options.append((styled_option, styled_message))
111
+ else:
112
+ global_group = GROUPS.get("Global options")
113
+ if global_group is not None:
114
+ global_group.options.append((styled_option, styled_message))
115
+
116
+ groups = sorted(GROUPS.values(), key=lambda g: g.order)
117
+ # Format each group
118
+ for group in groups:
119
+ with formatter.section(style(group.name, fg=SECTION_COLOR, bold=True)):
120
+ if group.description:
121
+ description = group.description
122
+ if use_color and group.name == "Filtering options":
123
+ description = _colorize_filtering_description(description, style)
124
+ formatter.write(textwrap.indent(description, " " * formatter.current_indent))
125
+ formatter.write("\n\n")
126
+
127
+ if group.options:
128
+ formatter.write_dl(group.options)
129
+
130
+
131
+ class StyledGroup(click.Group):
132
+ def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
133
+ use_color = should_use_color(ctx)
134
+
135
+ def style(text: str, **kwargs: Any) -> str:
136
+ # Only apply styling if colors are enabled
137
+ if use_color:
138
+ return click.style(text, **kwargs)
139
+ return text
140
+
141
+ options = []
142
+ for param in self.get_params(ctx):
143
+ rv = param.get_help_record(ctx)
144
+ if rv is not None:
145
+ option_repr, message = rv
146
+ options.append((style(option_repr, fg=OPTION_COLOR, bold=True), style(message, fg=HELP_COLOR)))
147
+
148
+ if options:
149
+ with formatter.section(style("Options", fg=SECTION_COLOR, bold=True)):
150
+ formatter.write_dl(options)
151
+
152
+ self.format_commands(ctx, formatter)
153
+
154
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
155
+ use_color = should_use_color(ctx)
156
+
157
+ def style(text: str, **kwargs: Any) -> str:
158
+ # Only apply styling if colors are enabled
159
+ if use_color:
160
+ return click.style(text, **kwargs)
161
+ return text
162
+
163
+ commands = []
164
+ for subcommand in self.list_commands(ctx):
165
+ cmd = self.get_command(ctx, subcommand)
166
+ if cmd is None:
167
+ continue
168
+ if cmd.hidden:
169
+ continue
170
+
171
+ commands.append((style(subcommand, fg=OPTION_COLOR, bold=True), cmd.get_short_help_str()))
172
+
173
+ if commands:
174
+ with formatter.section(style("Commands", fg=SECTION_COLOR, bold=True)):
175
+ formatter.write_dl(commands)
176
+
177
+
178
+ class GroupedOption(click.Option):
179
+ def __init__(self, *args: Any, group: str | None = None, **kwargs: Any):
180
+ super().__init__(*args, **kwargs)
181
+ self.group = group
182
+
183
+
184
+ def group(
185
+ name: str,
186
+ *,
187
+ description: str | None = None,
188
+ ) -> Callable:
189
+ GROUPS[name] = OptionGroup(name, description=description)
190
+
191
+ def _inner(cmd: Callable) -> Callable:
192
+ for param in reversed(cmd.__click_params__): # type: ignore[attr-defined]
193
+ if not isinstance(param, GroupedOption) or param.group is not None:
194
+ break
195
+ param.group = name
196
+ return cmd
197
+
198
+ return _inner
199
+
200
+
201
+ def grouped_option(*args: Any, **kwargs: Any) -> Callable:
202
+ kwargs.setdefault("cls", GroupedOption)
203
+ return click.option(*args, **kwargs)