schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 (229) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +26 -68
  3. schemathesis/checks.py +130 -60
  4. schemathesis/cli/__init__.py +5 -2105
  5. schemathesis/cli/commands/__init__.py +37 -0
  6. schemathesis/cli/commands/run/__init__.py +662 -0
  7. schemathesis/cli/commands/run/checks.py +80 -0
  8. schemathesis/cli/commands/run/context.py +117 -0
  9. schemathesis/cli/commands/run/events.py +30 -0
  10. schemathesis/cli/commands/run/executor.py +141 -0
  11. schemathesis/cli/commands/run/filters.py +202 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
  15. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1368 -0
  17. schemathesis/cli/commands/run/hypothesis.py +105 -0
  18. schemathesis/cli/commands/run/loaders.py +129 -0
  19. schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
  20. schemathesis/cli/constants.py +5 -58
  21. schemathesis/cli/core.py +17 -0
  22. schemathesis/cli/ext/fs.py +14 -0
  23. schemathesis/cli/ext/groups.py +55 -0
  24. schemathesis/cli/{options.py → ext/options.py} +37 -16
  25. schemathesis/cli/hooks.py +36 -0
  26. schemathesis/contrib/__init__.py +1 -3
  27. schemathesis/contrib/openapi/__init__.py +1 -3
  28. schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
  29. schemathesis/core/__init__.py +58 -0
  30. schemathesis/core/compat.py +25 -0
  31. schemathesis/core/control.py +2 -0
  32. schemathesis/core/curl.py +58 -0
  33. schemathesis/core/deserialization.py +65 -0
  34. schemathesis/core/errors.py +370 -0
  35. schemathesis/core/failures.py +315 -0
  36. schemathesis/core/fs.py +19 -0
  37. schemathesis/core/loaders.py +104 -0
  38. schemathesis/core/marks.py +66 -0
  39. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  40. schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
  41. schemathesis/core/output/sanitization.py +197 -0
  42. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  43. schemathesis/core/registries.py +31 -0
  44. schemathesis/core/transforms.py +113 -0
  45. schemathesis/core/transport.py +108 -0
  46. schemathesis/core/validation.py +38 -0
  47. schemathesis/core/version.py +7 -0
  48. schemathesis/engine/__init__.py +30 -0
  49. schemathesis/engine/config.py +59 -0
  50. schemathesis/engine/context.py +119 -0
  51. schemathesis/engine/control.py +36 -0
  52. schemathesis/engine/core.py +157 -0
  53. schemathesis/engine/errors.py +394 -0
  54. schemathesis/engine/events.py +243 -0
  55. schemathesis/engine/phases/__init__.py +66 -0
  56. schemathesis/{runner → engine/phases}/probes.py +49 -68
  57. schemathesis/engine/phases/stateful/__init__.py +66 -0
  58. schemathesis/engine/phases/stateful/_executor.py +301 -0
  59. schemathesis/engine/phases/stateful/context.py +85 -0
  60. schemathesis/engine/phases/unit/__init__.py +175 -0
  61. schemathesis/engine/phases/unit/_executor.py +322 -0
  62. schemathesis/engine/phases/unit/_pool.py +74 -0
  63. schemathesis/engine/recorder.py +246 -0
  64. schemathesis/errors.py +31 -0
  65. schemathesis/experimental/__init__.py +9 -40
  66. schemathesis/filters.py +7 -95
  67. schemathesis/generation/__init__.py +3 -3
  68. schemathesis/generation/case.py +190 -0
  69. schemathesis/generation/coverage.py +22 -22
  70. schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
  71. schemathesis/generation/hypothesis/builder.py +585 -0
  72. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  73. schemathesis/generation/hypothesis/given.py +66 -0
  74. schemathesis/generation/hypothesis/reporting.py +14 -0
  75. schemathesis/generation/hypothesis/strategies.py +16 -0
  76. schemathesis/generation/meta.py +115 -0
  77. schemathesis/generation/modes.py +28 -0
  78. schemathesis/generation/overrides.py +96 -0
  79. schemathesis/generation/stateful/__init__.py +20 -0
  80. schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
  81. schemathesis/generation/targets.py +69 -0
  82. schemathesis/graphql/__init__.py +15 -0
  83. schemathesis/graphql/checks.py +109 -0
  84. schemathesis/graphql/loaders.py +131 -0
  85. schemathesis/hooks.py +17 -62
  86. schemathesis/openapi/__init__.py +13 -0
  87. schemathesis/openapi/checks.py +387 -0
  88. schemathesis/openapi/generation/__init__.py +0 -0
  89. schemathesis/openapi/generation/filters.py +63 -0
  90. schemathesis/openapi/loaders.py +178 -0
  91. schemathesis/pytest/__init__.py +5 -0
  92. schemathesis/pytest/control_flow.py +7 -0
  93. schemathesis/pytest/lazy.py +273 -0
  94. schemathesis/pytest/loaders.py +12 -0
  95. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
  96. schemathesis/python/__init__.py +0 -0
  97. schemathesis/python/asgi.py +12 -0
  98. schemathesis/python/wsgi.py +12 -0
  99. schemathesis/schemas.py +456 -228
  100. schemathesis/specs/graphql/__init__.py +0 -1
  101. schemathesis/specs/graphql/_cache.py +1 -2
  102. schemathesis/specs/graphql/scalars.py +5 -3
  103. schemathesis/specs/graphql/schemas.py +122 -123
  104. schemathesis/specs/graphql/validation.py +11 -17
  105. schemathesis/specs/openapi/__init__.py +6 -1
  106. schemathesis/specs/openapi/_cache.py +1 -2
  107. schemathesis/specs/openapi/_hypothesis.py +97 -134
  108. schemathesis/specs/openapi/checks.py +238 -219
  109. schemathesis/specs/openapi/converter.py +4 -4
  110. schemathesis/specs/openapi/definitions.py +1 -1
  111. schemathesis/specs/openapi/examples.py +22 -20
  112. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  113. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  114. schemathesis/specs/openapi/expressions/nodes.py +33 -32
  115. schemathesis/specs/openapi/formats.py +3 -2
  116. schemathesis/specs/openapi/links.py +123 -299
  117. schemathesis/specs/openapi/media_types.py +10 -12
  118. schemathesis/specs/openapi/negative/__init__.py +2 -1
  119. schemathesis/specs/openapi/negative/mutations.py +3 -2
  120. schemathesis/specs/openapi/parameters.py +8 -6
  121. schemathesis/specs/openapi/patterns.py +1 -1
  122. schemathesis/specs/openapi/references.py +11 -51
  123. schemathesis/specs/openapi/schemas.py +177 -191
  124. schemathesis/specs/openapi/security.py +1 -1
  125. schemathesis/specs/openapi/serialization.py +10 -6
  126. schemathesis/specs/openapi/stateful/__init__.py +97 -91
  127. schemathesis/transport/__init__.py +104 -0
  128. schemathesis/transport/asgi.py +26 -0
  129. schemathesis/transport/prepare.py +99 -0
  130. schemathesis/transport/requests.py +221 -0
  131. schemathesis/{_xml.py → transport/serialization.py} +69 -7
  132. schemathesis/transport/wsgi.py +165 -0
  133. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
  134. schemathesis-4.0.0a2.dist-info/RECORD +151 -0
  135. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
  136. schemathesis/_compat.py +0 -74
  137. schemathesis/_dependency_versions.py +0 -19
  138. schemathesis/_hypothesis.py +0 -559
  139. schemathesis/_override.py +0 -50
  140. schemathesis/_rate_limiter.py +0 -7
  141. schemathesis/cli/context.py +0 -75
  142. schemathesis/cli/debug.py +0 -27
  143. schemathesis/cli/handlers.py +0 -19
  144. schemathesis/cli/junitxml.py +0 -124
  145. schemathesis/cli/output/__init__.py +0 -1
  146. schemathesis/cli/output/default.py +0 -936
  147. schemathesis/cli/output/short.py +0 -59
  148. schemathesis/cli/reporting.py +0 -79
  149. schemathesis/cli/sanitization.py +0 -26
  150. schemathesis/code_samples.py +0 -151
  151. schemathesis/constants.py +0 -56
  152. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  153. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  154. schemathesis/contrib/unique_data.py +0 -41
  155. schemathesis/exceptions.py +0 -571
  156. schemathesis/extra/_aiohttp.py +0 -28
  157. schemathesis/extra/_flask.py +0 -13
  158. schemathesis/extra/_server.py +0 -18
  159. schemathesis/failures.py +0 -277
  160. schemathesis/fixups/__init__.py +0 -37
  161. schemathesis/fixups/fast_api.py +0 -41
  162. schemathesis/fixups/utf8_bom.py +0 -28
  163. schemathesis/generation/_methods.py +0 -44
  164. schemathesis/graphql.py +0 -3
  165. schemathesis/internal/__init__.py +0 -7
  166. schemathesis/internal/checks.py +0 -84
  167. schemathesis/internal/copy.py +0 -32
  168. schemathesis/internal/datetime.py +0 -5
  169. schemathesis/internal/deprecation.py +0 -38
  170. schemathesis/internal/diff.py +0 -15
  171. schemathesis/internal/extensions.py +0 -27
  172. schemathesis/internal/jsonschema.py +0 -36
  173. schemathesis/internal/transformation.py +0 -26
  174. schemathesis/internal/validation.py +0 -34
  175. schemathesis/lazy.py +0 -474
  176. schemathesis/loaders.py +0 -122
  177. schemathesis/models.py +0 -1341
  178. schemathesis/parameters.py +0 -90
  179. schemathesis/runner/__init__.py +0 -605
  180. schemathesis/runner/events.py +0 -389
  181. schemathesis/runner/impl/__init__.py +0 -3
  182. schemathesis/runner/impl/context.py +0 -104
  183. schemathesis/runner/impl/core.py +0 -1246
  184. schemathesis/runner/impl/solo.py +0 -80
  185. schemathesis/runner/impl/threadpool.py +0 -391
  186. schemathesis/runner/serialization.py +0 -544
  187. schemathesis/sanitization.py +0 -252
  188. schemathesis/serializers.py +0 -328
  189. schemathesis/service/__init__.py +0 -18
  190. schemathesis/service/auth.py +0 -11
  191. schemathesis/service/ci.py +0 -202
  192. schemathesis/service/client.py +0 -133
  193. schemathesis/service/constants.py +0 -38
  194. schemathesis/service/events.py +0 -61
  195. schemathesis/service/extensions.py +0 -224
  196. schemathesis/service/hosts.py +0 -111
  197. schemathesis/service/metadata.py +0 -71
  198. schemathesis/service/models.py +0 -258
  199. schemathesis/service/report.py +0 -255
  200. schemathesis/service/serialization.py +0 -173
  201. schemathesis/service/usage.py +0 -66
  202. schemathesis/specs/graphql/loaders.py +0 -364
  203. schemathesis/specs/openapi/expressions/context.py +0 -16
  204. schemathesis/specs/openapi/loaders.py +0 -708
  205. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  206. schemathesis/specs/openapi/stateful/types.py +0 -14
  207. schemathesis/specs/openapi/validation.py +0 -26
  208. schemathesis/stateful/__init__.py +0 -147
  209. schemathesis/stateful/config.py +0 -97
  210. schemathesis/stateful/context.py +0 -135
  211. schemathesis/stateful/events.py +0 -274
  212. schemathesis/stateful/runner.py +0 -309
  213. schemathesis/stateful/sink.py +0 -68
  214. schemathesis/stateful/statistic.py +0 -22
  215. schemathesis/stateful/validation.py +0 -100
  216. schemathesis/targets.py +0 -77
  217. schemathesis/transports/__init__.py +0 -359
  218. schemathesis/transports/asgi.py +0 -7
  219. schemathesis/transports/auth.py +0 -38
  220. schemathesis/transports/headers.py +0 -36
  221. schemathesis/transports/responses.py +0 -57
  222. schemathesis/types.py +0 -44
  223. schemathesis/utils.py +0 -164
  224. schemathesis-3.39.7.dist-info/RECORD +0 -160
  225. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  226. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  227. /schemathesis/{internal → core}/result.py +0 -0
  228. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  229. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Sequence
5
+
6
+ from schemathesis import experimental
7
+ from schemathesis.checks import CHECKS, CheckFunction, ChecksConfig
8
+
9
+
10
+ @dataclass
11
+ class CheckArguments:
12
+ included_check_names: Sequence[str]
13
+ excluded_check_names: Sequence[str]
14
+ positive_data_acceptance_allowed_statuses: list[str] | None
15
+ missing_required_header_allowed_statuses: list[str] | None
16
+ negative_data_rejection_allowed_statuses: list[str] | None
17
+ max_response_time: float | None
18
+
19
+ __slots__ = (
20
+ "included_check_names",
21
+ "excluded_check_names",
22
+ "positive_data_acceptance_allowed_statuses",
23
+ "missing_required_header_allowed_statuses",
24
+ "negative_data_rejection_allowed_statuses",
25
+ "max_response_time",
26
+ )
27
+
28
+ def into(self) -> tuple[list[CheckFunction], ChecksConfig]:
29
+ # Determine selected checks
30
+ if "all" in self.included_check_names:
31
+ selected_checks = CHECKS.get_all()
32
+ else:
33
+ selected_checks = CHECKS.get_by_names(self.included_check_names or [])
34
+
35
+ # Prepare checks configuration
36
+ checks_config: ChecksConfig = {}
37
+
38
+ if experimental.POSITIVE_DATA_ACCEPTANCE.is_enabled:
39
+ from schemathesis.openapi.checks import PositiveDataAcceptanceConfig
40
+ from schemathesis.specs.openapi.checks import positive_data_acceptance
41
+
42
+ selected_checks.append(positive_data_acceptance)
43
+ if self.positive_data_acceptance_allowed_statuses:
44
+ checks_config[positive_data_acceptance] = PositiveDataAcceptanceConfig(
45
+ allowed_statuses=self.positive_data_acceptance_allowed_statuses
46
+ )
47
+
48
+ if self.missing_required_header_allowed_statuses:
49
+ from schemathesis.openapi.checks import MissingRequiredHeaderConfig
50
+ from schemathesis.specs.openapi.checks import missing_required_header
51
+
52
+ selected_checks.append(missing_required_header)
53
+ checks_config[missing_required_header] = MissingRequiredHeaderConfig(
54
+ allowed_statuses=self.missing_required_header_allowed_statuses
55
+ )
56
+
57
+ if self.negative_data_rejection_allowed_statuses:
58
+ from schemathesis.openapi.checks import NegativeDataRejectionConfig
59
+ from schemathesis.specs.openapi.checks import negative_data_rejection
60
+
61
+ checks_config[negative_data_rejection] = NegativeDataRejectionConfig(
62
+ allowed_statuses=self.negative_data_rejection_allowed_statuses
63
+ )
64
+
65
+ if self.max_response_time is not None:
66
+ from schemathesis.checks import max_response_time as _max_response_time
67
+ from schemathesis.core.failures import MaxResponseTimeConfig
68
+
69
+ checks_config[_max_response_time] = MaxResponseTimeConfig(self.max_response_time)
70
+ selected_checks.append(_max_response_time)
71
+
72
+ if experimental.COVERAGE_PHASE.is_enabled:
73
+ from schemathesis.specs.openapi.checks import unsupported_method
74
+
75
+ selected_checks.append(unsupported_method)
76
+
77
+ # Exclude checks based on their names
78
+ selected_checks = [check for check in selected_checks if check.__name__ not in self.excluded_check_names]
79
+
80
+ return selected_checks, checks_config
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Generator
5
+
6
+ from schemathesis.core.failures import Failure
7
+ from schemathesis.core.output import OutputConfig
8
+ from schemathesis.core.transport import Response
9
+ from schemathesis.engine import Status, events
10
+ from schemathesis.engine.recorder import ScenarioRecorder
11
+
12
+
13
+ @dataclass
14
+ class Statistic:
15
+ """Running statistics about test execution."""
16
+
17
+ outcomes: dict[Status, int]
18
+ failures: dict[str, dict[str, GroupedFailures]]
19
+
20
+ tested_operations: set[str]
21
+
22
+ total_cases: int
23
+ cases_with_failures: int
24
+ cases_without_checks: int
25
+
26
+ __slots__ = (
27
+ "outcomes",
28
+ "failures",
29
+ "tested_operations",
30
+ "total_cases",
31
+ "cases_with_failures",
32
+ "cases_without_checks",
33
+ )
34
+
35
+ def __init__(self) -> None:
36
+ self.outcomes = {}
37
+ self.failures = {}
38
+ self.tested_operations = set()
39
+ self.total_cases = 0
40
+ self.cases_with_failures = 0
41
+ self.cases_without_checks = 0
42
+
43
+ def record_checks(self, recorder: ScenarioRecorder) -> None:
44
+ """Update statistics and store failures from a new batch of checks."""
45
+ failures = self.failures.get(recorder.label, {})
46
+
47
+ self.total_cases += len(recorder.cases)
48
+
49
+ for case_id, case in recorder.cases.items():
50
+ checks = recorder.checks.get(case_id, [])
51
+
52
+ if not checks:
53
+ self.cases_without_checks += 1
54
+ continue
55
+
56
+ self.tested_operations.add(case.value.operation.label)
57
+ has_failures = False
58
+ for check in checks:
59
+ response = recorder.interactions[case_id].response
60
+
61
+ # Collect failures
62
+ if check.failure_info is not None:
63
+ has_failures = True
64
+ if case_id not in failures:
65
+ failures[case_id] = GroupedFailures(
66
+ case_id=case_id,
67
+ code_sample=check.failure_info.code_sample,
68
+ failures=[],
69
+ response=response,
70
+ )
71
+ failures[case_id].failures.append(check.failure_info.failure)
72
+ if has_failures:
73
+ self.cases_with_failures += 1
74
+ if failures:
75
+ for group in failures.values():
76
+ group.failures = sorted(set(group.failures))
77
+ self.failures[recorder.label] = failures
78
+
79
+
80
+ @dataclass
81
+ class GroupedFailures:
82
+ """Represents failures grouped by case ID."""
83
+
84
+ case_id: str
85
+ code_sample: str
86
+ failures: list[Failure]
87
+ response: Response | None
88
+
89
+ __slots__ = ("case_id", "code_sample", "failures", "response")
90
+
91
+
92
+ @dataclass
93
+ class ExecutionContext:
94
+ """Storage for the current context of the execution."""
95
+
96
+ statistic: Statistic = field(default_factory=Statistic)
97
+ exit_code: int = 0
98
+ output_config: OutputConfig = field(default_factory=OutputConfig)
99
+ initialization_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
100
+ summary_lines: list[str | Generator[str, None, None]] = field(default_factory=list)
101
+ seed: int | None = None
102
+
103
+ def add_initialization_line(self, line: str | Generator[str, None, None]) -> None:
104
+ self.initialization_lines.append(line)
105
+
106
+ def add_summary_line(self, line: str | Generator[str, None, None]) -> None:
107
+ self.summary_lines.append(line)
108
+
109
+ def on_event(self, event: events.EngineEvent) -> None:
110
+ if isinstance(event, events.ScenarioFinished):
111
+ self.statistic.record_checks(event.recorder)
112
+ elif isinstance(event, events.NonFatalError) or (
113
+ isinstance(event, events.PhaseFinished)
114
+ and event.phase.is_enabled
115
+ and event.status in (Status.FAILURE, Status.ERROR)
116
+ ):
117
+ self.exit_code = 1
@@ -0,0 +1,30 @@
1
+ import time
2
+ import uuid
3
+
4
+ from schemathesis.core import Specification
5
+ from schemathesis.engine import events
6
+ from schemathesis.schemas import ApiStatistic
7
+
8
+
9
+ class LoadingStarted(events.EngineEvent):
10
+ __slots__ = ("id", "timestamp", "location")
11
+
12
+ def __init__(self, *, location: str) -> None:
13
+ self.id = uuid.uuid4()
14
+ self.timestamp = time.time()
15
+ self.location = location
16
+
17
+
18
+ class LoadingFinished(events.EngineEvent):
19
+ __slots__ = ("id", "timestamp", "location", "duration", "base_url", "specification", "statistic")
20
+
21
+ def __init__(
22
+ self, location: str, start_time: float, base_url: str, specification: Specification, statistic: ApiStatistic
23
+ ) -> None:
24
+ self.id = uuid.uuid4()
25
+ self.timestamp = time.time()
26
+ self.location = location
27
+ self.duration = self.timestamp - start_time
28
+ self.base_url = base_url
29
+ self.specification = specification
30
+ self.statistic = statistic
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable
6
+
7
+ import click
8
+
9
+ from schemathesis.cli.commands.run.context import ExecutionContext
10
+ from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
11
+ from schemathesis.cli.commands.run.handlers import display_handler_error
12
+ from schemathesis.cli.commands.run.handlers.base import EventHandler
13
+ from schemathesis.cli.commands.run.handlers.cassettes import CassetteConfig, CassetteWriter
14
+ from schemathesis.cli.commands.run.handlers.junitxml import JunitXMLHandler
15
+ from schemathesis.cli.commands.run.handlers.output import OutputHandler
16
+ from schemathesis.cli.commands.run.loaders import AutodetectConfig, load_schema
17
+ from schemathesis.cli.ext.fs import open_file
18
+ from schemathesis.core.errors import LoaderError
19
+ from schemathesis.core.output import OutputConfig
20
+ from schemathesis.engine import from_schema
21
+ from schemathesis.engine.config import EngineConfig
22
+ from schemathesis.engine.events import EventGenerator, FatalError, Interrupted
23
+ from schemathesis.filters import FilterSet
24
+
25
+ CUSTOM_HANDLERS: list[type[EventHandler]] = []
26
+
27
+
28
+ def handler() -> Callable[[type], None]:
29
+ """Register a new CLI event handler."""
30
+
31
+ def _wrapper(cls: type) -> None:
32
+ CUSTOM_HANDLERS.append(cls)
33
+
34
+ return _wrapper
35
+
36
+
37
+ @dataclass
38
+ class RunConfig:
39
+ location: str
40
+ base_url: str | None
41
+ filter_set: FilterSet
42
+ engine: EngineConfig
43
+ wait_for_schema: float | None
44
+ rate_limit: str | None
45
+ output: OutputConfig
46
+ junit_xml: click.utils.LazyFile | None
47
+ cassette: CassetteConfig | None
48
+ args: list[str]
49
+ params: dict[str, Any]
50
+
51
+
52
+ def execute(config: RunConfig) -> None:
53
+ event_stream = into_event_stream(config)
54
+ _execute(event_stream, config)
55
+
56
+
57
+ def into_event_stream(config: RunConfig) -> EventGenerator:
58
+ loader_config = AutodetectConfig(
59
+ location=config.location,
60
+ network=config.engine.network,
61
+ wait_for_schema=config.wait_for_schema,
62
+ base_url=config.base_url,
63
+ rate_limit=config.rate_limit,
64
+ output=config.output,
65
+ generation=config.engine.execution.generation,
66
+ )
67
+ loading_started = LoadingStarted(location=config.location)
68
+ yield loading_started
69
+
70
+ try:
71
+ schema = load_schema(loader_config)
72
+ schema.filter_set = config.filter_set
73
+ except KeyboardInterrupt:
74
+ yield Interrupted(phase=None)
75
+ return
76
+ except LoaderError as exc:
77
+ yield FatalError(exception=exc)
78
+ return
79
+
80
+ yield LoadingFinished(
81
+ location=config.location,
82
+ start_time=loading_started.timestamp,
83
+ base_url=schema.get_base_url(),
84
+ specification=schema.specification,
85
+ statistic=schema.statistic,
86
+ )
87
+
88
+ try:
89
+ yield from from_schema(schema, config=config.engine).execute()
90
+ except Exception as exc:
91
+ yield FatalError(exception=exc)
92
+
93
+
94
+ def _execute(event_stream: EventGenerator, config: RunConfig) -> None:
95
+ handlers: list[EventHandler] = []
96
+ if config.junit_xml is not None:
97
+ open_file(config.junit_xml)
98
+ handlers.append(JunitXMLHandler(config.junit_xml))
99
+ if config.cassette is not None:
100
+ open_file(config.cassette.path)
101
+ handlers.append(CassetteWriter(config=config.cassette))
102
+ for custom_handler in CUSTOM_HANDLERS:
103
+ handlers.append(custom_handler(*config.args, **config.params))
104
+ handlers.append(
105
+ OutputHandler(
106
+ workers_num=config.engine.execution.workers_num,
107
+ rate_limit=config.rate_limit,
108
+ wait_for_schema=config.wait_for_schema,
109
+ cassette_config=config.cassette,
110
+ junit_xml_file=config.junit_xml.name if config.junit_xml is not None else None,
111
+ )
112
+ )
113
+
114
+ ctx = ExecutionContext(output_config=config.output, seed=config.engine.execution.seed)
115
+
116
+ def shutdown() -> None:
117
+ for _handler in handlers:
118
+ _handler.shutdown(ctx)
119
+
120
+ for handler in handlers:
121
+ handler.start(ctx)
122
+
123
+ try:
124
+ for event in event_stream:
125
+ ctx.on_event(event)
126
+ for handler in handlers:
127
+ try:
128
+ handler.handle_event(ctx, event)
129
+ except Exception as exc:
130
+ # `Abort` is used for handled errors
131
+ if not isinstance(exc, click.Abort):
132
+ display_handler_error(handler, exc)
133
+ raise
134
+ except Exception as exc:
135
+ if isinstance(exc, click.Abort):
136
+ # To avoid showing "Aborted!" message, which is the default behavior in Click
137
+ sys.exit(1)
138
+ raise
139
+ finally:
140
+ shutdown()
141
+ sys.exit(ctx.exit_code)
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Literal, Sequence
5
+
6
+ import click
7
+
8
+ from schemathesis.cli.ext.groups import grouped_option
9
+ from schemathesis.core.errors import IncorrectUsage
10
+ from schemathesis.filters import FilterSet, expression_to_filter_function, is_deprecated
11
+
12
+
13
+ def _with_filter(*, by: str, mode: Literal["include", "exclude"], modifier: Literal["regex"] | None) -> Callable:
14
+ """Generate a CLI option for filtering API operations."""
15
+ param = f"--{mode}-{by}"
16
+ action = "include in" if mode == "include" else "exclude from"
17
+ prop = {
18
+ "operation-id": "ID",
19
+ "name": "Operation name",
20
+ }.get(by, by.capitalize())
21
+ if modifier:
22
+ param += f"-{modifier}"
23
+ prop += " pattern"
24
+ help_text = f"{prop} to {action} testing."
25
+ return grouped_option(
26
+ param,
27
+ help=help_text,
28
+ type=str,
29
+ multiple=modifier is None,
30
+ )
31
+
32
+
33
+ _BY_VALUES = ("operation-id", "tag", "name", "method", "path")
34
+
35
+
36
+ def with_filters(command: Callable) -> Callable:
37
+ for by in _BY_VALUES:
38
+ for mode in ("exclude", "include"):
39
+ for modifier in ("regex", None):
40
+ command = _with_filter(by=by, mode=mode, modifier=modifier)(command) # type: ignore[arg-type]
41
+ return command
42
+
43
+
44
+ @dataclass
45
+ class FilterArguments:
46
+ include_path: Sequence[str]
47
+ include_method: Sequence[str]
48
+ include_name: Sequence[str]
49
+ include_tag: Sequence[str]
50
+ include_operation_id: Sequence[str]
51
+ include_path_regex: str | None
52
+ include_method_regex: str | None
53
+ include_name_regex: str | None
54
+ include_tag_regex: str | None
55
+ include_operation_id_regex: str | None
56
+
57
+ exclude_path: Sequence[str]
58
+ exclude_method: Sequence[str]
59
+ exclude_name: Sequence[str]
60
+ exclude_tag: Sequence[str]
61
+ exclude_operation_id: Sequence[str]
62
+ exclude_path_regex: str | None
63
+ exclude_method_regex: str | None
64
+ exclude_name_regex: str | None
65
+ exclude_tag_regex: str | None
66
+ exclude_operation_id_regex: str | None
67
+
68
+ include_by: str | None
69
+ exclude_by: str | None
70
+ exclude_deprecated: bool
71
+
72
+ __slots__ = (
73
+ "include_path",
74
+ "include_method",
75
+ "include_name",
76
+ "include_tag",
77
+ "include_operation_id",
78
+ "include_path_regex",
79
+ "include_method_regex",
80
+ "include_name_regex",
81
+ "include_tag_regex",
82
+ "include_operation_id_regex",
83
+ "exclude_path",
84
+ "exclude_method",
85
+ "exclude_name",
86
+ "exclude_tag",
87
+ "exclude_operation_id",
88
+ "exclude_path_regex",
89
+ "exclude_method_regex",
90
+ "exclude_name_regex",
91
+ "exclude_tag_regex",
92
+ "exclude_operation_id_regex",
93
+ "include_by",
94
+ "exclude_by",
95
+ "exclude_deprecated",
96
+ )
97
+
98
+ def into(self) -> FilterSet:
99
+ # Validate unique filter arguments
100
+ for values, arg_name in (
101
+ (self.include_path, "--include-path"),
102
+ (self.include_method, "--include-method"),
103
+ (self.include_name, "--include-name"),
104
+ (self.include_tag, "--include-tag"),
105
+ (self.include_operation_id, "--include-operation-id"),
106
+ (self.exclude_path, "--exclude-path"),
107
+ (self.exclude_method, "--exclude-method"),
108
+ (self.exclude_name, "--exclude-name"),
109
+ (self.exclude_tag, "--exclude-tag"),
110
+ (self.exclude_operation_id, "--exclude-operation-id"),
111
+ ):
112
+ validate_unique_filter(values, arg_name)
113
+
114
+ # Convert include/exclude expressions to functions
115
+ include_by_function = _filter_by_expression_to_func(self.include_by, "--include-by")
116
+ exclude_by_function = _filter_by_expression_to_func(self.exclude_by, "--exclude-by")
117
+
118
+ filter_set = FilterSet()
119
+
120
+ # Apply include filters
121
+ if include_by_function:
122
+ filter_set.include(include_by_function)
123
+ for name_ in self.include_name:
124
+ filter_set.include(name=name_)
125
+ for method in self.include_method:
126
+ filter_set.include(method=method)
127
+ for path in self.include_path:
128
+ filter_set.include(path=path)
129
+ for tag in self.include_tag:
130
+ filter_set.include(tag=tag)
131
+ for operation_id in self.include_operation_id:
132
+ filter_set.include(operation_id=operation_id)
133
+ if (
134
+ self.include_name_regex
135
+ or self.include_method_regex
136
+ or self.include_path_regex
137
+ or self.include_tag_regex
138
+ or self.include_operation_id_regex
139
+ ):
140
+ filter_set.include(
141
+ name_regex=self.include_name_regex,
142
+ method_regex=self.include_method_regex,
143
+ path_regex=self.include_path_regex,
144
+ tag_regex=self.include_tag_regex,
145
+ operation_id_regex=self.include_operation_id_regex,
146
+ )
147
+
148
+ # Apply exclude filters
149
+ if exclude_by_function:
150
+ filter_set.exclude(exclude_by_function)
151
+ for name_ in self.exclude_name:
152
+ apply_exclude_filter(filter_set, "name", name=name_)
153
+ for method in self.exclude_method:
154
+ apply_exclude_filter(filter_set, "method", method=method)
155
+ for path in self.exclude_path:
156
+ apply_exclude_filter(filter_set, "path", path=path)
157
+ for tag in self.exclude_tag:
158
+ apply_exclude_filter(filter_set, "tag", tag=tag)
159
+ for operation_id in self.exclude_operation_id:
160
+ apply_exclude_filter(filter_set, "operation-id", operation_id=operation_id)
161
+ for key, value, name in (
162
+ ("name_regex", self.exclude_name_regex, "name-regex"),
163
+ ("method_regex", self.exclude_method_regex, "method-regex"),
164
+ ("path_regex", self.exclude_path_regex, "path-regex"),
165
+ ("tag_regex", self.exclude_tag_regex, "tag-regex"),
166
+ ("operation_id_regex", self.exclude_operation_id_regex, "operation-id-regex"),
167
+ ):
168
+ if value:
169
+ apply_exclude_filter(filter_set, name, **{key: value})
170
+
171
+ # Exclude deprecated operations
172
+ if self.exclude_deprecated:
173
+ filter_set.exclude(is_deprecated)
174
+
175
+ return filter_set
176
+
177
+
178
+ def apply_exclude_filter(filter_set: FilterSet, option_name: str, **kwargs: Any) -> None:
179
+ """Apply an exclude filter with proper error handling."""
180
+ try:
181
+ filter_set.exclude(**kwargs)
182
+ except IncorrectUsage as e:
183
+ if str(e) == "Filter already exists":
184
+ raise click.UsageError(
185
+ f"Filter for {option_name} already exists. You can't simultaneously include and exclude the same thing."
186
+ ) from None
187
+ raise click.UsageError(str(e)) from None
188
+
189
+
190
+ def validate_unique_filter(values: Sequence[str], arg_name: str) -> None:
191
+ if len(values) != len(set(values)):
192
+ duplicates = ",".join(sorted({value for value in values if values.count(value) > 1}))
193
+ raise click.UsageError(f"Duplicate values are not allowed for `{arg_name}`: {duplicates}")
194
+
195
+
196
+ def _filter_by_expression_to_func(value: str | None, arg_name: str) -> Callable | None:
197
+ if value:
198
+ try:
199
+ return expression_to_filter_function(value)
200
+ except ValueError:
201
+ raise click.UsageError(f"Invalid expression for {arg_name}: {value}") from None
202
+ return None
@@ -0,0 +1,46 @@
1
+ import click
2
+
3
+ from schemathesis.cli.commands.run.handlers.base import EventHandler
4
+ from schemathesis.cli.commands.run.handlers.cassettes import CassetteWriter
5
+ from schemathesis.cli.commands.run.handlers.junitxml import JunitXMLHandler
6
+ from schemathesis.cli.commands.run.handlers.output import OutputHandler
7
+ from schemathesis.cli.constants import EXTENSIONS_DOCUMENTATION_URL, ISSUE_TRACKER_URL
8
+ from schemathesis.core.errors import format_exception
9
+
10
+ __all__ = [
11
+ "EventHandler",
12
+ "CassetteWriter",
13
+ "JunitXMLHandler",
14
+ "OutputHandler",
15
+ "display_handler_error",
16
+ ]
17
+
18
+
19
+ def is_built_in_handler(handler: EventHandler) -> bool:
20
+ # Look for exact instances, not subclasses
21
+ return any(type(handler) is class_ for class_ in (CassetteWriter, JunitXMLHandler, OutputHandler))
22
+
23
+
24
+ def display_handler_error(handler: EventHandler, exc: Exception) -> None:
25
+ """Display error that happened within."""
26
+ is_built_in = is_built_in_handler(handler)
27
+ if is_built_in:
28
+ click.secho("Internal Error", fg="red", bold=True)
29
+ click.secho("\nSchemathesis encountered an unexpected issue.")
30
+ message = format_exception(exc, with_traceback=True)
31
+ else:
32
+ click.secho("CLI Handler Error", fg="red", bold=True)
33
+ click.echo(
34
+ f"\nAn error occurred within your custom CLI handler `{click.style(handler.__class__.__name__, bold=True)}`."
35
+ )
36
+ message = format_exception(exc, with_traceback=True, skip_frames=1)
37
+ click.secho(f"\n{message}", fg="red")
38
+ if is_built_in:
39
+ click.echo(
40
+ f"\nWe apologize for the inconvenience. This appears to be an internal issue.\n"
41
+ f"Please consider reporting this error to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
42
+ )
43
+ else:
44
+ click.echo(
45
+ f"\nFor more information on implementing extensions for Schemathesis CLI, visit {EXTENSIONS_DOCUMENTATION_URL}"
46
+ )
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from schemathesis.cli.commands.run.context import ExecutionContext
7
+ from schemathesis.engine import events
8
+
9
+
10
+ class EventHandler:
11
+ def __init__(self, *args: Any, **params: Any) -> None: ...
12
+
13
+ def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
14
+ raise NotImplementedError
15
+
16
+ def start(self, ctx: ExecutionContext) -> None: ...
17
+
18
+ def shutdown(self, ctx: ExecutionContext) -> None: ...