schemathesis 3.39.15__py3-none-any.whl → 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. schemathesis/__init__.py +41 -79
  2. schemathesis/auths.py +111 -122
  3. schemathesis/checks.py +169 -60
  4. schemathesis/cli/__init__.py +15 -2117
  5. schemathesis/cli/commands/__init__.py +85 -0
  6. schemathesis/cli/commands/data.py +10 -0
  7. schemathesis/cli/commands/run/__init__.py +590 -0
  8. schemathesis/cli/commands/run/context.py +204 -0
  9. schemathesis/cli/commands/run/events.py +60 -0
  10. schemathesis/cli/commands/run/executor.py +157 -0
  11. schemathesis/cli/commands/run/filters.py +53 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
  15. schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1628 -0
  17. schemathesis/cli/commands/run/loaders.py +114 -0
  18. schemathesis/cli/commands/run/validation.py +246 -0
  19. schemathesis/cli/constants.py +5 -58
  20. schemathesis/cli/core.py +19 -0
  21. schemathesis/cli/ext/fs.py +16 -0
  22. schemathesis/cli/ext/groups.py +84 -0
  23. schemathesis/cli/{options.py → ext/options.py} +36 -34
  24. schemathesis/config/__init__.py +189 -0
  25. schemathesis/config/_auth.py +51 -0
  26. schemathesis/config/_checks.py +268 -0
  27. schemathesis/config/_diff_base.py +99 -0
  28. schemathesis/config/_env.py +21 -0
  29. schemathesis/config/_error.py +156 -0
  30. schemathesis/config/_generation.py +149 -0
  31. schemathesis/config/_health_check.py +24 -0
  32. schemathesis/config/_operations.py +327 -0
  33. schemathesis/config/_output.py +171 -0
  34. schemathesis/config/_parameters.py +19 -0
  35. schemathesis/config/_phases.py +187 -0
  36. schemathesis/config/_projects.py +527 -0
  37. schemathesis/config/_rate_limit.py +17 -0
  38. schemathesis/config/_report.py +120 -0
  39. schemathesis/config/_validator.py +9 -0
  40. schemathesis/config/_warnings.py +25 -0
  41. schemathesis/config/schema.json +885 -0
  42. schemathesis/core/__init__.py +67 -0
  43. schemathesis/core/compat.py +32 -0
  44. schemathesis/core/control.py +2 -0
  45. schemathesis/core/curl.py +58 -0
  46. schemathesis/core/deserialization.py +65 -0
  47. schemathesis/core/errors.py +459 -0
  48. schemathesis/core/failures.py +315 -0
  49. schemathesis/core/fs.py +19 -0
  50. schemathesis/core/hooks.py +20 -0
  51. schemathesis/core/loaders.py +104 -0
  52. schemathesis/core/marks.py +66 -0
  53. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  54. schemathesis/core/output/__init__.py +46 -0
  55. schemathesis/core/output/sanitization.py +54 -0
  56. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  57. schemathesis/core/registries.py +31 -0
  58. schemathesis/core/transforms.py +113 -0
  59. schemathesis/core/transport.py +223 -0
  60. schemathesis/core/validation.py +54 -0
  61. schemathesis/core/version.py +7 -0
  62. schemathesis/engine/__init__.py +28 -0
  63. schemathesis/engine/context.py +118 -0
  64. schemathesis/engine/control.py +36 -0
  65. schemathesis/engine/core.py +169 -0
  66. schemathesis/engine/errors.py +464 -0
  67. schemathesis/engine/events.py +258 -0
  68. schemathesis/engine/phases/__init__.py +88 -0
  69. schemathesis/{runner → engine/phases}/probes.py +52 -68
  70. schemathesis/engine/phases/stateful/__init__.py +68 -0
  71. schemathesis/engine/phases/stateful/_executor.py +356 -0
  72. schemathesis/engine/phases/stateful/context.py +85 -0
  73. schemathesis/engine/phases/unit/__init__.py +212 -0
  74. schemathesis/engine/phases/unit/_executor.py +416 -0
  75. schemathesis/engine/phases/unit/_pool.py +82 -0
  76. schemathesis/engine/recorder.py +247 -0
  77. schemathesis/errors.py +43 -0
  78. schemathesis/filters.py +17 -98
  79. schemathesis/generation/__init__.py +5 -33
  80. schemathesis/generation/case.py +317 -0
  81. schemathesis/generation/coverage.py +282 -175
  82. schemathesis/generation/hypothesis/__init__.py +36 -0
  83. schemathesis/generation/hypothesis/builder.py +800 -0
  84. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  85. schemathesis/generation/hypothesis/given.py +66 -0
  86. schemathesis/generation/hypothesis/reporting.py +14 -0
  87. schemathesis/generation/hypothesis/strategies.py +16 -0
  88. schemathesis/generation/meta.py +115 -0
  89. schemathesis/generation/metrics.py +93 -0
  90. schemathesis/generation/modes.py +20 -0
  91. schemathesis/generation/overrides.py +116 -0
  92. schemathesis/generation/stateful/__init__.py +37 -0
  93. schemathesis/generation/stateful/state_machine.py +278 -0
  94. schemathesis/graphql/__init__.py +15 -0
  95. schemathesis/graphql/checks.py +109 -0
  96. schemathesis/graphql/loaders.py +284 -0
  97. schemathesis/hooks.py +80 -101
  98. schemathesis/openapi/__init__.py +13 -0
  99. schemathesis/openapi/checks.py +455 -0
  100. schemathesis/openapi/generation/__init__.py +0 -0
  101. schemathesis/openapi/generation/filters.py +72 -0
  102. schemathesis/openapi/loaders.py +313 -0
  103. schemathesis/pytest/__init__.py +5 -0
  104. schemathesis/pytest/control_flow.py +7 -0
  105. schemathesis/pytest/lazy.py +281 -0
  106. schemathesis/pytest/loaders.py +36 -0
  107. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
  108. schemathesis/python/__init__.py +0 -0
  109. schemathesis/python/asgi.py +12 -0
  110. schemathesis/python/wsgi.py +12 -0
  111. schemathesis/schemas.py +537 -273
  112. schemathesis/specs/graphql/__init__.py +0 -1
  113. schemathesis/specs/graphql/_cache.py +1 -2
  114. schemathesis/specs/graphql/scalars.py +42 -6
  115. schemathesis/specs/graphql/schemas.py +141 -137
  116. schemathesis/specs/graphql/validation.py +11 -17
  117. schemathesis/specs/openapi/__init__.py +6 -1
  118. schemathesis/specs/openapi/_cache.py +1 -2
  119. schemathesis/specs/openapi/_hypothesis.py +142 -156
  120. schemathesis/specs/openapi/checks.py +368 -257
  121. schemathesis/specs/openapi/converter.py +4 -4
  122. schemathesis/specs/openapi/definitions.py +1 -1
  123. schemathesis/specs/openapi/examples.py +23 -21
  124. schemathesis/specs/openapi/expressions/__init__.py +31 -19
  125. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  126. schemathesis/specs/openapi/expressions/lexer.py +1 -1
  127. schemathesis/specs/openapi/expressions/nodes.py +36 -41
  128. schemathesis/specs/openapi/expressions/parser.py +1 -1
  129. schemathesis/specs/openapi/formats.py +35 -7
  130. schemathesis/specs/openapi/media_types.py +53 -12
  131. schemathesis/specs/openapi/negative/__init__.py +7 -4
  132. schemathesis/specs/openapi/negative/mutations.py +6 -5
  133. schemathesis/specs/openapi/parameters.py +7 -10
  134. schemathesis/specs/openapi/patterns.py +94 -31
  135. schemathesis/specs/openapi/references.py +12 -53
  136. schemathesis/specs/openapi/schemas.py +238 -308
  137. schemathesis/specs/openapi/security.py +1 -1
  138. schemathesis/specs/openapi/serialization.py +12 -6
  139. schemathesis/specs/openapi/stateful/__init__.py +268 -133
  140. schemathesis/specs/openapi/stateful/control.py +87 -0
  141. schemathesis/specs/openapi/stateful/links.py +209 -0
  142. schemathesis/transport/__init__.py +142 -0
  143. schemathesis/transport/asgi.py +26 -0
  144. schemathesis/transport/prepare.py +124 -0
  145. schemathesis/transport/requests.py +244 -0
  146. schemathesis/{_xml.py → transport/serialization.py} +69 -11
  147. schemathesis/transport/wsgi.py +171 -0
  148. schemathesis-4.0.0.dist-info/METADATA +204 -0
  149. schemathesis-4.0.0.dist-info/RECORD +164 -0
  150. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
  151. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
  152. schemathesis/_compat.py +0 -74
  153. schemathesis/_dependency_versions.py +0 -19
  154. schemathesis/_hypothesis.py +0 -712
  155. schemathesis/_override.py +0 -50
  156. schemathesis/_patches.py +0 -21
  157. schemathesis/_rate_limiter.py +0 -7
  158. schemathesis/cli/callbacks.py +0 -466
  159. schemathesis/cli/cassettes.py +0 -561
  160. schemathesis/cli/context.py +0 -75
  161. schemathesis/cli/debug.py +0 -27
  162. schemathesis/cli/handlers.py +0 -19
  163. schemathesis/cli/junitxml.py +0 -124
  164. schemathesis/cli/output/__init__.py +0 -1
  165. schemathesis/cli/output/default.py +0 -920
  166. schemathesis/cli/output/short.py +0 -59
  167. schemathesis/cli/reporting.py +0 -79
  168. schemathesis/cli/sanitization.py +0 -26
  169. schemathesis/code_samples.py +0 -151
  170. schemathesis/constants.py +0 -54
  171. schemathesis/contrib/__init__.py +0 -11
  172. schemathesis/contrib/openapi/__init__.py +0 -11
  173. schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
  174. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  175. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  176. schemathesis/contrib/unique_data.py +0 -41
  177. schemathesis/exceptions.py +0 -571
  178. schemathesis/experimental/__init__.py +0 -109
  179. schemathesis/extra/_aiohttp.py +0 -28
  180. schemathesis/extra/_flask.py +0 -13
  181. schemathesis/extra/_server.py +0 -18
  182. schemathesis/failures.py +0 -284
  183. schemathesis/fixups/__init__.py +0 -37
  184. schemathesis/fixups/fast_api.py +0 -41
  185. schemathesis/fixups/utf8_bom.py +0 -28
  186. schemathesis/generation/_methods.py +0 -44
  187. schemathesis/graphql.py +0 -3
  188. schemathesis/internal/__init__.py +0 -7
  189. schemathesis/internal/checks.py +0 -86
  190. schemathesis/internal/copy.py +0 -32
  191. schemathesis/internal/datetime.py +0 -5
  192. schemathesis/internal/deprecation.py +0 -37
  193. schemathesis/internal/diff.py +0 -15
  194. schemathesis/internal/extensions.py +0 -27
  195. schemathesis/internal/jsonschema.py +0 -36
  196. schemathesis/internal/output.py +0 -68
  197. schemathesis/internal/transformation.py +0 -26
  198. schemathesis/internal/validation.py +0 -34
  199. schemathesis/lazy.py +0 -474
  200. schemathesis/loaders.py +0 -122
  201. schemathesis/models.py +0 -1341
  202. schemathesis/parameters.py +0 -90
  203. schemathesis/runner/__init__.py +0 -605
  204. schemathesis/runner/events.py +0 -389
  205. schemathesis/runner/impl/__init__.py +0 -3
  206. schemathesis/runner/impl/context.py +0 -88
  207. schemathesis/runner/impl/core.py +0 -1280
  208. schemathesis/runner/impl/solo.py +0 -80
  209. schemathesis/runner/impl/threadpool.py +0 -391
  210. schemathesis/runner/serialization.py +0 -544
  211. schemathesis/sanitization.py +0 -252
  212. schemathesis/serializers.py +0 -328
  213. schemathesis/service/__init__.py +0 -18
  214. schemathesis/service/auth.py +0 -11
  215. schemathesis/service/ci.py +0 -202
  216. schemathesis/service/client.py +0 -133
  217. schemathesis/service/constants.py +0 -38
  218. schemathesis/service/events.py +0 -61
  219. schemathesis/service/extensions.py +0 -224
  220. schemathesis/service/hosts.py +0 -111
  221. schemathesis/service/metadata.py +0 -71
  222. schemathesis/service/models.py +0 -258
  223. schemathesis/service/report.py +0 -255
  224. schemathesis/service/serialization.py +0 -173
  225. schemathesis/service/usage.py +0 -66
  226. schemathesis/specs/graphql/loaders.py +0 -364
  227. schemathesis/specs/openapi/expressions/context.py +0 -16
  228. schemathesis/specs/openapi/links.py +0 -389
  229. schemathesis/specs/openapi/loaders.py +0 -707
  230. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  231. schemathesis/specs/openapi/stateful/types.py +0 -14
  232. schemathesis/specs/openapi/validation.py +0 -26
  233. schemathesis/stateful/__init__.py +0 -147
  234. schemathesis/stateful/config.py +0 -97
  235. schemathesis/stateful/context.py +0 -135
  236. schemathesis/stateful/events.py +0 -274
  237. schemathesis/stateful/runner.py +0 -309
  238. schemathesis/stateful/sink.py +0 -68
  239. schemathesis/stateful/state_machine.py +0 -328
  240. schemathesis/stateful/statistic.py +0 -22
  241. schemathesis/stateful/validation.py +0 -100
  242. schemathesis/targets.py +0 -77
  243. schemathesis/transports/__init__.py +0 -369
  244. schemathesis/transports/asgi.py +0 -7
  245. schemathesis/transports/auth.py +0 -38
  246. schemathesis/transports/headers.py +0 -36
  247. schemathesis/transports/responses.py +0 -57
  248. schemathesis/types.py +0 -44
  249. schemathesis/utils.py +0 -164
  250. schemathesis-3.39.15.dist-info/METADATA +0 -293
  251. schemathesis-3.39.15.dist-info/RECORD +0 -160
  252. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  253. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  254. /schemathesis/{internal → core}/result.py +0 -0
  255. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,114 @@
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 typing import TYPE_CHECKING, Any, Callable
12
+
13
+ from schemathesis import graphql, openapi
14
+ from schemathesis.config import ProjectConfig
15
+ from schemathesis.core.errors import LoaderError, LoaderErrorKind
16
+ from schemathesis.core.fs import file_exists
17
+
18
+ if TYPE_CHECKING:
19
+ from schemathesis.schemas import BaseSchema
20
+
21
+ Loader = Callable[["ProjectConfig"], "BaseSchema"]
22
+
23
+
24
+ def load_schema(location: str, config: ProjectConfig) -> BaseSchema:
25
+ """Load API schema automatically based on the provided configuration."""
26
+ if is_probably_graphql(location):
27
+ # Try GraphQL first, then fallback to Open API
28
+ return _try_load_schema(location, config, graphql, openapi)
29
+ # Try Open API first, then fallback to GraphQL
30
+ return _try_load_schema(location, config, openapi, graphql)
31
+
32
+
33
+ def should_try_more(exc: LoaderError) -> bool:
34
+ """Determine if alternative schema loading should be attempted."""
35
+ import requests
36
+ from yaml.reader import ReaderError
37
+
38
+ if isinstance(exc.__cause__, ReaderError) and "characters are not allowed" in str(exc.__cause__):
39
+ return False
40
+
41
+ # We should not try other loaders for cases when we can't even establish connection
42
+ return not isinstance(exc.__cause__, requests.exceptions.ConnectionError) and exc.kind not in (
43
+ LoaderErrorKind.OPEN_API_INVALID_SCHEMA,
44
+ LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION,
45
+ LoaderErrorKind.OPEN_API_UNSUPPORTED_VERSION,
46
+ )
47
+
48
+
49
+ def detect_loader(location: str, module: Any) -> Callable:
50
+ """Detect API schema loader."""
51
+ if file_exists(location):
52
+ return module.from_path # type: ignore
53
+ return module.from_url # type: ignore
54
+
55
+
56
+ def _try_load_schema(location: str, config: ProjectConfig, first_module: Any, second_module: Any) -> BaseSchema:
57
+ """Try to load schema with fallback option."""
58
+ from urllib3.exceptions import InsecureRequestWarning
59
+
60
+ with warnings.catch_warnings():
61
+ warnings.simplefilter("ignore", InsecureRequestWarning)
62
+ try:
63
+ return _load_schema(location, config, first_module)
64
+ except LoaderError as exc:
65
+ # If this was the OpenAPI loader on an explicit OpenAPI file, don't fallback
66
+ if first_module is openapi and is_openapi_file(location):
67
+ raise exc
68
+ if should_try_more(exc):
69
+ try:
70
+ return _load_schema(location, config, second_module)
71
+ except Exception as second_exc:
72
+ if is_specific_exception(second_exc):
73
+ raise second_exc
74
+ # Re-raise the original error
75
+ raise exc
76
+
77
+
78
+ def _load_schema(location: str, config: ProjectConfig, module: Any) -> BaseSchema:
79
+ """Unified schema loader for both GraphQL and OpenAPI."""
80
+ loader = detect_loader(location, module)
81
+
82
+ kwargs: dict = {}
83
+ if loader is module.from_url:
84
+ if config.wait_for_schema is not None:
85
+ kwargs["wait_for_schema"] = config.wait_for_schema
86
+ kwargs["verify"] = config.tls_verify
87
+ request_cert = config.request_cert_for()
88
+ if request_cert:
89
+ kwargs["cert"] = request_cert
90
+ auth = config.auth_for()
91
+ if auth is not None:
92
+ kwargs["auth"] = auth
93
+
94
+ return loader(location, config=config._parent, **kwargs)
95
+
96
+
97
+ def is_specific_exception(exc: Exception) -> bool:
98
+ """Determine if alternative schema loading should be attempted."""
99
+ return (
100
+ isinstance(exc, LoaderError)
101
+ and exc.kind == LoaderErrorKind.GRAPHQL_INVALID_SCHEMA
102
+ # In some cases it is not clear that the schema is even supposed to be GraphQL, e.g. an empty input
103
+ and "Syntax Error: Unexpected <EOF>." not in exc.extras
104
+ )
105
+
106
+
107
+ def is_probably_graphql(location: str) -> bool:
108
+ """Detect whether it is likely that the given location is a GraphQL endpoint."""
109
+ return location.endswith(("/graphql", "/graphql/", ".graphql", ".gql"))
110
+
111
+
112
+ def is_openapi_file(location: str) -> bool:
113
+ name = os.path.basename(location).lower()
114
+ return any(name == f"{base}{ext}" for base in ("openapi", "swagger") for ext in (".json", ".yaml", ".yml"))
@@ -0,0 +1,246 @@
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, {}) # type: ignore
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(ctx: click.core.Context, param: click.core.Parameter, raw_value: str) -> str:
77
+ try:
78
+ codecs.getencoder(raw_value)
79
+ except LookupError as exc:
80
+ raise click.UsageError(f"Codec `{raw_value}` is unknown") from exc
81
+ return raw_value
82
+
83
+
84
+ def validate_rate_limit(ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None) -> str | None:
85
+ if raw_value is None:
86
+ return raw_value
87
+ try:
88
+ rate_limit.parse_units(raw_value)
89
+ return raw_value
90
+ except errors.IncorrectUsage as exc:
91
+ raise click.UsageError(exc.args[0]) from exc
92
+
93
+
94
+ def validate_hypothesis_database(
95
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
96
+ ) -> str | None:
97
+ if raw_value is None:
98
+ return raw_value
99
+ if ctx.params.get("generation_deterministic"):
100
+ raise click.UsageError(INVALID_DERANDOMIZE_MESSAGE)
101
+ return raw_value
102
+
103
+
104
+ def validate_auth(
105
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
106
+ ) -> tuple[str, str] | None:
107
+ if raw_value is not None:
108
+ with reraise_format_error(raw_value):
109
+ user, password = tuple(raw_value.split(":"))
110
+ if not user:
111
+ raise click.BadParameter("Username should not be empty.")
112
+ if not is_latin_1_encodable(user):
113
+ raise click.BadParameter("Username should be latin-1 encodable.")
114
+ if not is_latin_1_encodable(password):
115
+ raise click.BadParameter("Password should be latin-1 encodable.")
116
+ return user, password
117
+ return None
118
+
119
+
120
+ def validate_auth_overlap(auth: tuple[str, str] | None, headers: dict[str, str]) -> None:
121
+ auth_is_set = auth is not None
122
+ header_is_set = "authorization" in {header.lower() for header in headers}
123
+ if len([is_set for is_set in (auth_is_set, header_is_set) if is_set]) > 1:
124
+ message = "The "
125
+ used = []
126
+ if auth_is_set:
127
+ used.append("`--auth`")
128
+ if header_is_set:
129
+ used.append("`--header`")
130
+ message += " and ".join(used)
131
+ message += " options were both used to set the 'Authorization' header, which is not permitted."
132
+ raise click.BadParameter(message)
133
+
134
+
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:
139
+ try:
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
145
+
146
+
147
+ def _validate_header(key: str, value: str, where: str) -> None:
148
+ if not key:
149
+ raise click.BadParameter(f"{where} name should not be empty.")
150
+ if not is_latin_1_encodable(key):
151
+ raise click.BadParameter(f"{where} name should be latin-1 encodable.")
152
+ if not is_latin_1_encodable(value):
153
+ raise click.BadParameter(f"{where} value should be latin-1 encodable.")
154
+ if has_invalid_characters(key, value):
155
+ raise click.BadParameter(f"Invalid return character or leading space in {where.lower()}.")
156
+
157
+
158
+ def validate_headers(
159
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: tuple[str, ...]
160
+ ) -> dict[str, str]:
161
+ headers = {}
162
+ for header in raw_value:
163
+ with reraise_format_error(header):
164
+ key, value = header.split(":", maxsplit=1)
165
+ value = value.lstrip()
166
+ key = key.strip()
167
+ _validate_header(key, value, where="Header")
168
+ headers[key] = value
169
+ return headers
170
+
171
+
172
+ def validate_request_cert_key(
173
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
174
+ ) -> str | None:
175
+ if raw_value is not None and "request_cert" not in ctx.params:
176
+ raise click.UsageError(MISSING_REQUEST_CERT_MESSAGE)
177
+ return raw_value
178
+
179
+
180
+ def validate_preserve_bytes(ctx: click.core.Context, param: click.core.Parameter, raw_value: bool) -> bool:
181
+ if not raw_value:
182
+ return False
183
+
184
+ report_formats = ctx.params.get("report_formats", []) or []
185
+ vcr_enabled = ReportFormat.VCR in report_formats or ctx.params.get("report_vcr_path")
186
+ har_enabled = ReportFormat.HAR in report_formats or ctx.params.get("report_har_path")
187
+
188
+ if not (vcr_enabled or har_enabled):
189
+ raise click.UsageError(INVALID_REPORT_USAGE)
190
+
191
+ return True
192
+
193
+
194
+ def reduce_list(
195
+ ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]] | None
196
+ ) -> list[str] | None:
197
+ if not value:
198
+ return None
199
+ return reduce(operator.iadd, value, [])
200
+
201
+
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
206
+
207
+ names: list[str] = reduce(operator.iadd, value, [])
208
+ return METRICS.get_by_names(names)
209
+
210
+
211
+ def convert_generation_mode(ctx: click.core.Context, param: click.core.Parameter, value: str) -> list[GenerationMode]:
212
+ if value == "all":
213
+ return list(GenerationMode)
214
+ return [GenerationMode(value)]
215
+
216
+
217
+ def convert_boolean_string(ctx: click.core.Context, param: click.core.Parameter, value: str) -> str | bool:
218
+ return string_to_boolean(value)
219
+
220
+
221
+ @contextmanager
222
+ def reraise_format_error(raw_value: str) -> Generator[None, None, None]:
223
+ try:
224
+ yield
225
+ except ValueError as exc:
226
+ raise click.BadParameter(f"Expected KEY:VALUE format, received {raw_value}.") from exc
227
+
228
+
229
+ def convert_workers(ctx: click.core.Context, param: click.core.Parameter, value: str) -> int:
230
+ if value == "auto":
231
+ return get_workers_count()
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]
@@ -1,61 +1,8 @@
1
- from __future__ import annotations
2
-
3
- from enum import IntEnum, unique
4
- from typing import TYPE_CHECKING
5
-
6
- if TYPE_CHECKING:
7
- import hypothesis
8
-
9
1
  MIN_WORKERS = 1
10
2
  DEFAULT_WORKERS = MIN_WORKERS
11
3
  MAX_WORKERS = 64
12
-
13
- # Importing Hypothesis is expensive, hence we re-create the enums we need in CLI commands definitions
14
- # Hypothesis is stable, hence it should not be a problem and adding new variants should not be automatic
15
-
16
-
17
- @unique
18
- class Phase(IntEnum):
19
- explicit = 0 #: controls whether explicit examples are run.
20
- reuse = 1 #: controls whether previous examples will be reused.
21
- generate = 2 #: controls whether new examples will be generated.
22
- target = 3 #: controls whether examples will be mutated for targeting.
23
- shrink = 4 #: controls whether examples will be shrunk.
24
- # The `explain` phase is not supported
25
-
26
- def as_hypothesis(self) -> hypothesis.Phase:
27
- from hypothesis import Phase
28
-
29
- return Phase[self.name]
30
-
31
- @staticmethod
32
- def filter_from_all(variants: list[Phase]) -> list[hypothesis.Phase]:
33
- from hypothesis import Phase
34
-
35
- return list(set(Phase) - {Phase.explain} - set(variants))
36
-
37
-
38
- @unique
39
- class HealthCheck(IntEnum):
40
- # We remove not relevant checks
41
- data_too_large = 1
42
- filter_too_much = 2
43
- too_slow = 3
44
- large_base_example = 7
45
- all = 8
46
-
47
- def as_hypothesis(self) -> list[hypothesis.HealthCheck]:
48
- from hypothesis import HealthCheck
49
-
50
- if self.name == "all":
51
- return list(HealthCheck)
52
-
53
- return [HealthCheck[self.name]]
54
-
55
-
56
- @unique
57
- class Verbosity(IntEnum):
58
- quiet = 0
59
- normal = 1
60
- verbose = 2
61
- debug = 3
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 as exc:
16
+ raise click.BadParameter(f"Could not open file {file.name}: {exc}") from exc
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import textwrap
4
+ from typing import Any, Callable
5
+
6
+ import click
7
+
8
+ GROUPS: dict[str, OptionGroup] = {}
9
+
10
+
11
+ class OptionGroup:
12
+ __slots__ = ("order", "name", "description", "options")
13
+
14
+ def __init__(
15
+ self,
16
+ name: str,
17
+ *,
18
+ order: int | None = None,
19
+ description: str | None = None,
20
+ ):
21
+ self.name = name
22
+ self.description = description
23
+ self.order = order if order is not None else len(GROUPS) * 100
24
+ self.options: list[tuple[str, str]] = []
25
+
26
+
27
+ class CommandWithGroupedOptions(click.Command):
28
+ def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
29
+ # Collect options into groups or ungrouped list
30
+ for param in self.get_params(ctx):
31
+ rv = param.get_help_record(ctx)
32
+ if rv is not None:
33
+ option_repr, message = rv
34
+ if isinstance(param.type, click.Choice):
35
+ message += (
36
+ getattr(param.type, "choices_repr", None)
37
+ or f" [possible values: {', '.join(param.type.choices)}]"
38
+ )
39
+
40
+ if isinstance(param, GroupedOption) and param.group is not None:
41
+ group = GROUPS.get(param.group)
42
+ if group:
43
+ group.options.append((option_repr, message))
44
+ else:
45
+ GROUPS["Global options"].options.append((option_repr, message))
46
+
47
+ groups = sorted(GROUPS.values(), key=lambda g: g.order)
48
+ # Format each group
49
+ for group in groups:
50
+ with formatter.section(group.name):
51
+ if group.description:
52
+ formatter.write(textwrap.indent(group.description, " " * formatter.current_indent))
53
+ formatter.write("\n\n")
54
+
55
+ if group.options:
56
+ formatter.write_dl(group.options)
57
+
58
+
59
+ class GroupedOption(click.Option):
60
+ def __init__(self, *args: Any, group: str | None = None, **kwargs: Any):
61
+ super().__init__(*args, **kwargs)
62
+ self.group = group
63
+
64
+
65
+ def group(
66
+ name: str,
67
+ *,
68
+ description: str | None = None,
69
+ ) -> Callable:
70
+ GROUPS[name] = OptionGroup(name, description=description)
71
+
72
+ def _inner(cmd: Callable) -> Callable:
73
+ for param in reversed(cmd.__click_params__): # type: ignore[attr-defined]
74
+ if not isinstance(param, GroupedOption) or param.group is not None:
75
+ break
76
+ param.group = name
77
+ return cmd
78
+
79
+ return _inner
80
+
81
+
82
+ def grouped_option(*args: Any, **kwargs: Any) -> Callable:
83
+ kwargs.setdefault("cls", GroupedOption)
84
+ return click.option(*args, **kwargs)
@@ -1,15 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING, Any, NoReturn
3
+ from enum import Enum
4
+ from typing import Any, NoReturn
4
5
 
5
6
  import click
6
7
 
7
- from ..constants import NOT_SET
8
-
9
- if TYPE_CHECKING:
10
- from enum import Enum
11
-
12
- from ..types import NotSet
8
+ from schemathesis.core.registries import Registry
13
9
 
14
10
 
15
11
  class CustomHelpMessageChoice(click.Choice):
@@ -25,7 +21,7 @@ class CustomHelpMessageChoice(click.Choice):
25
21
 
26
22
  class BaseCsvChoice(click.Choice):
27
23
  def parse_value(self, value: str) -> tuple[list[str], set[str]]:
28
- selected = [item for item in value.split(",") if item]
24
+ selected = [item.strip() for item in value.split(",") if item.strip()]
29
25
  if not self.case_sensitive:
30
26
  invalid_options = {
31
27
  item for item in selected if item.upper() not in {choice.upper() for choice in self.choices}
@@ -34,50 +30,56 @@ class BaseCsvChoice(click.Choice):
34
30
  invalid_options = set(selected) - set(self.choices)
35
31
  return selected, invalid_options
36
32
 
37
- def fail_on_invalid_options(self, invalid_options: set[str], selected: list[str]) -> NoReturn:
33
+ def fail_on_invalid_options(self, invalid_options: set[str], selected: list[str]) -> NoReturn: # type: ignore[misc]
38
34
  # Sort to keep the error output consistent with the passed values
39
35
  sorted_options = ", ".join(sorted(invalid_options, key=selected.index))
40
36
  available_options = ", ".join(self.choices)
41
37
  self.fail(f"invalid choice(s): {sorted_options}. Choose from {available_options}.")
42
38
 
43
39
 
44
- class CsvEnumChoice(BaseCsvChoice):
45
- def __init__(self, choices: type[Enum]):
46
- self.enum = choices
47
- super().__init__(tuple(el.name for el in choices))
48
-
40
+ class CsvChoice(BaseCsvChoice):
49
41
  def convert( # type: ignore[return]
50
42
  self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
51
- ) -> list[Enum]:
43
+ ) -> list[str]:
52
44
  selected, invalid_options = self.parse_value(value)
53
45
  if not invalid_options and selected:
54
- return [self.enum[item] for item in selected]
46
+ return selected
55
47
  self.fail_on_invalid_options(invalid_options, selected)
56
48
 
57
49
 
58
- class CsvChoice(BaseCsvChoice):
59
- def convert(self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None) -> list[str]:
50
+ class CsvEnumChoice(BaseCsvChoice):
51
+ def __init__(self, choices: type[Enum], case_sensitive: bool = False):
52
+ self.enum = choices
53
+ super().__init__(tuple(el.name.lower() for el in choices), case_sensitive=case_sensitive)
54
+
55
+ def convert(self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None) -> list[Enum]:
60
56
  selected, invalid_options = self.parse_value(value)
61
57
  if not invalid_options and selected:
62
- return selected
58
+ # Match case-insensitively to find the correct enum
59
+ return [
60
+ next(enum_value for enum_value in self.enum if enum_value.value.upper() == item.upper())
61
+ for item in selected
62
+ ]
63
63
  self.fail_on_invalid_options(invalid_options, selected)
64
64
 
65
65
 
66
- class CsvListChoice(click.ParamType):
67
- def convert( # type: ignore[return]
68
- self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
69
- ) -> list[str]:
70
- return [item for item in value.split(",") if item]
66
+ class RegistryChoice(BaseCsvChoice):
67
+ def __init__(self, registry: Registry, with_all: bool = False) -> None:
68
+ self.registry = registry
69
+ self.case_sensitive = True
70
+ self.with_all = with_all
71
71
 
72
+ @property
73
+ def choices(self) -> list[str]:
74
+ choices = self.registry.get_all_names()
75
+ if self.with_all:
76
+ choices.append("all")
77
+ return choices
72
78
 
73
- class OptionalInt(click.types.IntRange):
74
- def convert( # type: ignore
79
+ def convert( # type: ignore[return]
75
80
  self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
76
- ) -> int | NotSet:
77
- if value.lower() == "none":
78
- return NOT_SET
79
- try:
80
- int(value)
81
- return super().convert(value, param, ctx)
82
- except ValueError:
83
- self.fail(f"{value} is not a valid integer or None.", param, ctx)
81
+ ) -> list[str]:
82
+ selected, invalid_options = self.parse_value(value)
83
+ if not invalid_options and selected:
84
+ return selected
85
+ self.fail_on_invalid_options(invalid_options, selected)