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,105 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum, unique
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import click
7
+
8
+ if TYPE_CHECKING:
9
+ import hypothesis
10
+
11
+ PHASES_INVALID_USAGE_MESSAGE = "Can't use `--hypothesis-phases` and `--hypothesis-no-phases` simultaneously"
12
+ HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER = ":memory:"
13
+
14
+ # Importing Hypothesis is expensive, hence we re-create the enums we need in CLI commands definitions
15
+ # Hypothesis is stable, hence it should not be a problem and adding new variants should not be automatic
16
+
17
+
18
+ @unique
19
+ class Phase(IntEnum):
20
+ explicit = 0 #: controls whether explicit examples are run.
21
+ reuse = 1 #: controls whether previous examples will be reused.
22
+ generate = 2 #: controls whether new examples will be generated.
23
+ target = 3 #: controls whether examples will be mutated for targeting.
24
+ shrink = 4 #: controls whether examples will be shrunk.
25
+ # The `explain` phase is not supported
26
+
27
+ def as_hypothesis(self) -> hypothesis.Phase:
28
+ from hypothesis import Phase
29
+
30
+ return Phase[self.name]
31
+
32
+ @staticmethod
33
+ def filter_from_all(variants: list[Phase]) -> list[hypothesis.Phase]:
34
+ from hypothesis import Phase
35
+
36
+ return list(set(Phase) - {Phase.explain} - set(variants))
37
+
38
+
39
+ @unique
40
+ class HealthCheck(IntEnum):
41
+ # We remove not relevant checks
42
+ data_too_large = 1
43
+ filter_too_much = 2
44
+ too_slow = 3
45
+ large_base_example = 7
46
+ all = 8
47
+
48
+ def as_hypothesis(self) -> list[hypothesis.HealthCheck]:
49
+ from hypothesis import HealthCheck
50
+
51
+ if self.name == "all":
52
+ return list(HealthCheck)
53
+
54
+ return [HealthCheck[self.name]]
55
+
56
+
57
+ def prepare_health_checks(
58
+ hypothesis_suppress_health_check: list[HealthCheck] | None,
59
+ ) -> list[hypothesis.HealthCheck] | None:
60
+ if hypothesis_suppress_health_check is None:
61
+ return None
62
+
63
+ return [entry for health_check in hypothesis_suppress_health_check for entry in health_check.as_hypothesis()]
64
+
65
+
66
+ def prepare_phases(
67
+ hypothesis_phases: list[Phase] | None, hypothesis_no_phases: list[Phase] | None
68
+ ) -> list[hypothesis.Phase] | None:
69
+ if hypothesis_phases is not None and hypothesis_no_phases is not None:
70
+ raise click.UsageError(PHASES_INVALID_USAGE_MESSAGE)
71
+ if hypothesis_phases:
72
+ return [phase.as_hypothesis() for phase in hypothesis_phases]
73
+ if hypothesis_no_phases:
74
+ return Phase.filter_from_all(hypothesis_no_phases)
75
+ return None
76
+
77
+
78
+ def prepare_settings(
79
+ database: str | None = None,
80
+ derandomize: bool | None = None,
81
+ max_examples: int | None = None,
82
+ phases: list[hypothesis.Phase] | None = None,
83
+ suppress_health_check: list[hypothesis.HealthCheck] | None = None,
84
+ ) -> hypothesis.settings:
85
+ import hypothesis
86
+ from hypothesis.database import DirectoryBasedExampleDatabase, InMemoryExampleDatabase
87
+
88
+ kwargs: dict[str, Any] = {
89
+ key: value
90
+ for key, value in (
91
+ ("derandomize", derandomize),
92
+ ("max_examples", max_examples),
93
+ ("phases", phases),
94
+ ("suppress_health_check", suppress_health_check),
95
+ )
96
+ if value is not None
97
+ }
98
+ if database is not None:
99
+ if database.lower() == "none":
100
+ kwargs["database"] = None
101
+ elif database == HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER:
102
+ kwargs["database"] = InMemoryExampleDatabase()
103
+ else:
104
+ kwargs["database"] = DirectoryBasedExampleDatabase(database)
105
+ return hypothesis.settings(print_blob=False, deadline=None, verbosity=hypothesis.Verbosity.quiet, **kwargs)
@@ -0,0 +1,129 @@
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 warnings
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Any, Callable
12
+
13
+ from schemathesis import graphql, openapi
14
+ from schemathesis.core import NOT_SET, NotSet
15
+ from schemathesis.core.errors import LoaderError, LoaderErrorKind
16
+ from schemathesis.core.fs import file_exists
17
+ from schemathesis.core.output import OutputConfig
18
+ from schemathesis.generation import GenerationConfig
19
+
20
+ if TYPE_CHECKING:
21
+ from schemathesis.engine.config import NetworkConfig
22
+ from schemathesis.schemas import BaseSchema
23
+
24
+ Loader = Callable[["AutodetectConfig"], "BaseSchema"]
25
+
26
+
27
+ @dataclass
28
+ class AutodetectConfig:
29
+ location: str
30
+ network: NetworkConfig
31
+ wait_for_schema: float | None
32
+ base_url: str | None | NotSet = NOT_SET
33
+ rate_limit: str | None | NotSet = NOT_SET
34
+ generation: GenerationConfig | NotSet = NOT_SET
35
+ output: OutputConfig | NotSet = NOT_SET
36
+
37
+
38
+ def load_schema(config: AutodetectConfig) -> BaseSchema:
39
+ """Load API schema automatically based on the provided configuration."""
40
+ if is_probably_graphql(config.location):
41
+ # Try GraphQL first, then fallback to Open API
42
+ return _try_load_schema(config, graphql, openapi)
43
+ # Try Open API first, then fallback to GraphQL
44
+ return _try_load_schema(config, openapi, graphql)
45
+
46
+
47
+ def should_try_more(exc: LoaderError) -> bool:
48
+ """Determine if alternative schema loading should be attempted."""
49
+ import requests
50
+ from yaml.reader import ReaderError
51
+
52
+ if isinstance(exc.__cause__, ReaderError) and "characters are not allowed" in str(exc.__cause__):
53
+ return False
54
+
55
+ # We should not try other loaders for cases when we can't even establish connection
56
+ return not isinstance(exc.__cause__, requests.exceptions.ConnectionError) and exc.kind not in (
57
+ LoaderErrorKind.OPEN_API_INVALID_SCHEMA,
58
+ LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION,
59
+ LoaderErrorKind.OPEN_API_UNSUPPORTED_VERSION,
60
+ )
61
+
62
+
63
+ def detect_loader(schema_or_location: str | dict[str, Any], module: Any) -> Callable:
64
+ """Detect API schema loader."""
65
+ if isinstance(schema_or_location, str):
66
+ if file_exists(schema_or_location):
67
+ return module.from_path # type: ignore
68
+ return module.from_url # type: ignore
69
+ raise NotImplementedError
70
+
71
+
72
+ def _try_load_schema(config: AutodetectConfig, first_module: Any, second_module: Any) -> BaseSchema:
73
+ """Try to load schema with fallback option."""
74
+ from urllib3.exceptions import InsecureRequestWarning
75
+
76
+ with warnings.catch_warnings():
77
+ warnings.simplefilter("ignore", InsecureRequestWarning)
78
+ try:
79
+ return _load_schema(config, first_module)
80
+ except LoaderError as exc:
81
+ if should_try_more(exc):
82
+ try:
83
+ return _load_schema(config, second_module)
84
+ except Exception as second_exc:
85
+ if is_specific_exception(second_exc):
86
+ raise second_exc
87
+ # Re-raise the original error
88
+ raise exc
89
+
90
+
91
+ def _load_schema(config: AutodetectConfig, module: Any) -> BaseSchema:
92
+ """Unified schema loader for both GraphQL and OpenAPI."""
93
+ loader = detect_loader(config.location, module)
94
+
95
+ kwargs: dict = {}
96
+ if loader is module.from_url:
97
+ if config.wait_for_schema is not None:
98
+ kwargs["wait_for_schema"] = config.wait_for_schema
99
+ kwargs["verify"] = config.network.tls_verify
100
+ if config.network.cert:
101
+ kwargs["cert"] = config.network.cert
102
+ if config.network.auth:
103
+ kwargs["auth"] = config.network.auth
104
+
105
+ return loader(config.location, **kwargs).configure(
106
+ base_url=config.base_url,
107
+ rate_limit=config.rate_limit,
108
+ output=config.output,
109
+ generation=config.generation,
110
+ )
111
+
112
+
113
+ def is_specific_exception(exc: Exception) -> bool:
114
+ """Determine if alternative schema loading should be attempted."""
115
+ return (
116
+ isinstance(exc, LoaderError)
117
+ and exc.kind == LoaderErrorKind.GRAPHQL_INVALID_SCHEMA
118
+ # In some cases it is not clear that the schema is even supposed to be GraphQL, e.g. an empty input
119
+ and "Syntax Error: Unexpected <EOF>." not in exc.extras
120
+ )
121
+
122
+
123
+ def is_probably_graphql(schema_or_location: str | dict[str, Any]) -> bool:
124
+ """Detect whether it is likely that the given location is a GraphQL endpoint."""
125
+ if isinstance(schema_or_location, str):
126
+ return schema_or_location.endswith(("/graphql", "/graphql/", ".graphql", ".gql"))
127
+ return "__schema" in schema_or_location or (
128
+ "data" in schema_or_location and "__schema" in schema_or_location["data"]
129
+ )
@@ -1,127 +1,60 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import codecs
4
- import enum
5
4
  import operator
6
5
  import os
6
+ import pathlib
7
7
  import re
8
- import traceback
9
8
  from contextlib import contextmanager
10
9
  from functools import partial, reduce
11
- from typing import TYPE_CHECKING, Callable, Generator
10
+ from typing import Callable, Generator, Sequence
12
11
  from urllib.parse import urlparse
13
12
 
14
13
  import click
15
14
 
16
- from .. import exceptions, experimental, throttling
17
- from ..code_samples import CodeSampleStyle
18
- from ..constants import TRUE_VALUES
19
- from ..exceptions import extract_nth_traceback
20
- from ..generation import DataGenerationMethod
21
- from ..internal.transformation import convert_boolean_string as _convert_boolean_string
22
- from ..internal.validation import file_exists, is_filename, is_illegal_surrogate
23
- from ..loaders import load_app
24
- from ..service.hosts import get_temporary_hosts_file
25
- from ..stateful import Stateful
26
- from ..transports.headers import has_invalid_characters, is_latin_1_encodable
27
- from .cassettes import CassetteFormat
28
- from .constants import DEFAULT_WORKERS
29
-
30
- if TYPE_CHECKING:
31
- import hypothesis
32
- from click.types import LazyFile # type: ignore[attr-defined]
33
-
34
- from ..types import PathLike
15
+ from schemathesis import errors, experimental
16
+ from schemathesis.cli.commands.run.handlers.cassettes import CassetteFormat
17
+ from schemathesis.cli.constants import DEFAULT_WORKERS
18
+ from schemathesis.core import rate_limit, string_to_boolean
19
+ from schemathesis.core.fs import file_exists
20
+ from schemathesis.core.validation import contains_unicode_surrogate_pair, has_invalid_characters, is_latin_1_encodable
21
+ from schemathesis.generation import GenerationMode
22
+ from schemathesis.generation.overrides import Override
35
23
 
36
24
  INVALID_DERANDOMIZE_MESSAGE = (
37
- "`--hypothesis-derandomize` implies no database, so passing `--hypothesis-database` too is invalid."
25
+ "`--generation-deterministic` implies no database, so passing `--generation-database` too is invalid."
38
26
  )
39
27
  MISSING_CASSETTE_PATH_ARGUMENT_MESSAGE = (
40
28
  "Missing argument, `--cassette-path` should be specified as well if you use `--cassette-preserve-exact-body-bytes`."
41
29
  )
42
- INVALID_SCHEMA_MESSAGE = "Invalid SCHEMA, must be a valid URL, file path or an API name from Schemathesis.io."
30
+ INVALID_SCHEMA_MESSAGE = "Invalid SCHEMA, must be a valid URL or file path."
43
31
  FILE_DOES_NOT_EXIST_MESSAGE = "The specified file does not exist. Please provide a valid path to an existing file."
44
32
  INVALID_BASE_URL_MESSAGE = (
45
33
  "The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
46
34
  "Make sure it is a properly formatted URL."
47
35
  )
48
36
  MISSING_BASE_URL_MESSAGE = "The `--base-url` option is required when specifying a schema via a file."
49
- WSGI_DOCUMENTATION_URL = "https://schemathesis.readthedocs.io/en/stable/python.html#asgi-wsgi-support"
50
- APPLICATION_MISSING_MODULE_MESSAGE = f"""Unable to import application from {{module}}.
51
-
52
- The `--app` option should follow this format:
53
-
54
- module_path:variable_name
55
-
56
- - `module_path`: A path to an importable Python module.
57
- - `variable_name`: The name of the application variable within that module.
58
-
59
- Example: `st run --app=your_module:app ...`
60
-
61
- For details on working with WSGI applications, visit {WSGI_DOCUMENTATION_URL}"""
62
- APPLICATION_IMPORT_ERROR_MESSAGE = f"""An error occurred while loading the application from {{module}}.
63
-
64
- Traceback:
65
-
66
- {{traceback}}
67
-
68
- For details on working with WSGI applications, visit {WSGI_DOCUMENTATION_URL}"""
69
37
  MISSING_REQUEST_CERT_MESSAGE = "The `--request-cert` option must be specified if `--request-cert-key` is used."
70
38
 
71
39
 
72
- @enum.unique
73
- class SchemaInputKind(enum.Enum):
74
- """Kinds of SCHEMA input."""
75
-
76
- # Regular URL like https://example.schemathesis.io/openapi.json
77
- URL = 1
78
- # Local path
79
- PATH = 2
80
- # Relative path within a Python app
81
- APP_PATH = 3
82
- # A name for API created in Schemathesis.io
83
- NAME = 4
84
-
85
-
86
- def parse_schema_kind(schema: str, app: str | None) -> SchemaInputKind:
87
- """Detect what kind the input schema is."""
40
+ def validate_schema(schema: str, base_url: str | None) -> None:
88
41
  try:
89
42
  netloc = urlparse(schema).netloc
43
+ if netloc:
44
+ validate_url(schema)
45
+ return None
90
46
  except ValueError as exc:
91
47
  raise click.UsageError(INVALID_SCHEMA_MESSAGE) from exc
92
48
  if "\x00" in schema or not schema:
93
49
  raise click.UsageError(INVALID_SCHEMA_MESSAGE)
94
- if netloc:
95
- return SchemaInputKind.URL
96
- if file_exists(schema) or is_filename(schema):
97
- return SchemaInputKind.PATH
98
- if app is not None:
99
- return SchemaInputKind.APP_PATH
100
- # Assume NAME if it is not a URL or PATH or APP_PATH
101
- return SchemaInputKind.NAME
102
-
103
-
104
- def validate_schema(
105
- schema: str,
106
- kind: SchemaInputKind,
107
- *,
108
- base_url: str | None,
109
- dry_run: bool,
110
- app: str | None,
111
- api_name: str | None,
112
- ) -> None:
113
- if kind == SchemaInputKind.URL:
114
- validate_url(schema)
115
- if kind == SchemaInputKind.PATH:
116
- if app is None:
117
- if not file_exists(schema):
118
- raise click.UsageError(FILE_DOES_NOT_EXIST_MESSAGE)
119
- # Base URL is required if it is not a dry run
120
- if base_url is None and not dry_run:
121
- raise click.UsageError(MISSING_BASE_URL_MESSAGE)
122
- if kind == SchemaInputKind.NAME:
123
- if api_name is not None:
124
- raise click.UsageError(f"Got unexpected extra argument ({api_name})")
50
+ exists = file_exists(schema)
51
+ if exists or bool(pathlib.Path(schema).suffix):
52
+ if not exists:
53
+ raise click.UsageError(FILE_DOES_NOT_EXIST_MESSAGE)
54
+ if base_url is None:
55
+ raise click.UsageError(MISSING_BASE_URL_MESSAGE)
56
+ return None
57
+ raise click.UsageError(INVALID_SCHEMA_MESSAGE)
125
58
 
126
59
 
127
60
  def validate_url(value: str) -> None:
@@ -155,43 +88,18 @@ def validate_rate_limit(ctx: click.core.Context, param: click.core.Parameter, ra
155
88
  if raw_value is None:
156
89
  return raw_value
157
90
  try:
158
- throttling.parse_units(raw_value)
91
+ rate_limit.parse_units(raw_value)
159
92
  return raw_value
160
- except exceptions.UsageError as exc:
93
+ except errors.IncorrectUsage as exc:
161
94
  raise click.UsageError(exc.args[0]) from exc
162
95
 
163
96
 
164
- def validate_app(ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None) -> str | None:
165
- if raw_value is None:
166
- return raw_value
167
- try:
168
- load_app(raw_value)
169
- # String is returned instead of an app because it might be passed to a subprocess
170
- # Since most app instances are not-transferable to another process, they are passed as strings and
171
- # imported in a subprocess
172
- return raw_value
173
- except Exception as exc:
174
- formatted_module_name = click.style(f"'{raw_value}'", bold=True)
175
- if isinstance(exc, ModuleNotFoundError):
176
- message = APPLICATION_MISSING_MODULE_MESSAGE.format(module=formatted_module_name)
177
- click.echo(message)
178
- else:
179
- trace = extract_nth_traceback(exc.__traceback__, 2)
180
- lines = traceback.format_exception(type(exc), exc, trace)
181
- traceback_message = "".join(lines).strip()
182
- message = APPLICATION_IMPORT_ERROR_MESSAGE.format(
183
- module=formatted_module_name, traceback=click.style(traceback_message, fg="red")
184
- )
185
- click.echo(message)
186
- raise click.exceptions.Exit(1) from None
187
-
188
-
189
97
  def validate_hypothesis_database(
190
98
  ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
191
99
  ) -> str | None:
192
100
  if raw_value is None:
193
101
  return raw_value
194
- if ctx.params.get("hypothesis_derandomize"):
102
+ if ctx.params.get("generation_deterministic"):
195
103
  raise click.UsageError(INVALID_DERANDOMIZE_MESSAGE)
196
104
  return raw_value
197
105
 
@@ -212,6 +120,24 @@ def validate_auth(
212
120
  return None
213
121
 
214
122
 
123
+ def validate_auth_overlap(auth: tuple[str, str] | None, headers: dict[str, str], override: Override) -> None:
124
+ auth_is_set = auth is not None
125
+ header_is_set = "authorization" in {header.lower() for header in headers}
126
+ override_is_set = "authorization" in {header.lower() for header in override.headers}
127
+ if len([is_set for is_set in (auth_is_set, header_is_set, override_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
+ if override_is_set:
135
+ used.append("`--set-header`")
136
+ message += " and ".join(used)
137
+ message += " options were both used to set the 'Authorization' header, which is not permitted."
138
+ raise click.BadParameter(message)
139
+
140
+
215
141
  def _validate_and_build_multiple_options(
216
142
  values: tuple[str, ...], name: str, callback: Callable[[str, str], None]
217
143
  ) -> dict[str, str]:
@@ -232,8 +158,14 @@ def _validate_and_build_multiple_options(
232
158
  return output
233
159
 
234
160
 
161
+ def validate_unique_filter(values: Sequence[str], arg_name: str) -> None:
162
+ if len(values) != len(set(values)):
163
+ duplicates = ",".join(sorted({value for value in values if values.count(value) > 1}))
164
+ raise click.UsageError(f"Duplicate values are not allowed for `{arg_name}`: {duplicates}")
165
+
166
+
235
167
  def _validate_set_query(_: str, value: str) -> None:
236
- if is_illegal_surrogate(value):
168
+ if contains_unicode_surrogate_pair(value):
237
169
  raise click.BadParameter("Query parameter value should not contain surrogates.")
238
170
 
239
171
 
@@ -256,7 +188,7 @@ def validate_set_cookie(
256
188
 
257
189
 
258
190
  def _validate_set_path(_: str, value: str) -> None:
259
- if is_illegal_surrogate(value):
191
+ if contains_unicode_surrogate_pair(value):
260
192
  raise click.BadParameter("Path parameter value should not contain surrogates.")
261
193
 
262
194
 
@@ -314,33 +246,17 @@ def validate_preserve_exact_body_bytes(ctx: click.core.Context, param: click.cor
314
246
  return raw_value
315
247
 
316
248
 
317
- def convert_verbosity(
318
- ctx: click.core.Context, param: click.core.Parameter, value: str | None
319
- ) -> hypothesis.Verbosity | None:
320
- import hypothesis
321
-
322
- if value is None:
323
- return value
324
- return hypothesis.Verbosity[value]
325
-
326
-
327
- def convert_stateful(ctx: click.core.Context, param: click.core.Parameter, value: str) -> Stateful | None:
328
- if value == "none":
329
- return None
330
- return Stateful[value]
331
-
332
-
333
249
  def convert_experimental(
334
250
  ctx: click.core.Context, param: click.core.Parameter, value: tuple[str, ...]
335
251
  ) -> list[experimental.Experiment]:
336
252
  return [
337
253
  feature
338
254
  for feature in experimental.GLOBAL_EXPERIMENTS.available
339
- if feature.name in value or feature.is_env_var_set
255
+ if feature.label in value or feature.is_env_var_set
340
256
  ]
341
257
 
342
258
 
343
- def convert_checks(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
259
+ def reduce_list(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
344
260
  return reduce(operator.iadd, value, [])
345
261
 
346
262
 
@@ -386,50 +302,18 @@ def convert_status_codes(
386
302
  return value
387
303
 
388
304
 
389
- def convert_code_sample_style(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CodeSampleStyle:
390
- return CodeSampleStyle.from_str(value)
391
-
392
-
393
305
  def convert_cassette_format(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CassetteFormat:
394
306
  return CassetteFormat.from_str(value)
395
307
 
396
308
 
397
- def convert_data_generation_method(
398
- ctx: click.core.Context, param: click.core.Parameter, value: str
399
- ) -> list[DataGenerationMethod]:
309
+ def convert_generation_mode(ctx: click.core.Context, param: click.core.Parameter, value: str) -> list[GenerationMode]:
400
310
  if value == "all":
401
- return DataGenerationMethod.all()
402
- return [DataGenerationMethod[value]]
403
-
404
-
405
- def _is_usable_dir(path: PathLike) -> bool:
406
- if os.path.isfile(path):
407
- path = os.path.dirname(path)
408
- while not os.path.exists(path):
409
- path = os.path.dirname(path)
410
- return os.path.isdir(path) and os.access(path, os.R_OK | os.W_OK | os.X_OK)
411
-
412
-
413
- def convert_hosts_file(ctx: click.core.Context, param: click.core.Parameter, value: PathLike) -> PathLike:
414
- if not _is_usable_dir(value):
415
- path = get_temporary_hosts_file()
416
- click.secho(
417
- "WARNING: The provided hosts.toml file location is unusable - using a temporary file for this session. "
418
- f"path={str(value)!r}",
419
- fg="yellow",
420
- )
421
- return path
422
- return value
311
+ return GenerationMode.all()
312
+ return [GenerationMode(value)]
423
313
 
424
314
 
425
315
  def convert_boolean_string(ctx: click.core.Context, param: click.core.Parameter, value: str) -> str | bool:
426
- return _convert_boolean_string(value)
427
-
428
-
429
- def convert_report(ctx: click.core.Context, param: click.core.Option, value: LazyFile) -> LazyFile:
430
- if param.resolve_envvar_value(ctx) is not None and value.lower() in TRUE_VALUES:
431
- value = param.flag_value
432
- return value
316
+ return string_to_boolean(value)
433
317
 
434
318
 
435
319
  @contextmanager
@@ -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/extending.html"
@@ -0,0 +1,17 @@
1
+ import os
2
+ import shutil
3
+
4
+ import click
5
+
6
+
7
+ def get_terminal_width() -> int:
8
+ # Some CI/CD providers (e.g. CircleCI) return a (0, 0) terminal size so provide a default
9
+ return shutil.get_terminal_size((80, 24)).columns
10
+
11
+
12
+ def ensure_color(ctx: click.Context, no_color: bool, force_color: bool) -> None:
13
+ if force_color:
14
+ ctx.color = True
15
+ elif no_color or "NO_COLOR" in os.environ:
16
+ ctx.color = False
17
+ os.environ["NO_COLOR"] = "1"
@@ -0,0 +1,14 @@
1
+ import click
2
+
3
+ from schemathesis.core.fs import ensure_parent
4
+
5
+
6
+ def open_file(file: click.utils.LazyFile) -> None:
7
+ try:
8
+ ensure_parent(file.name, fail_silently=False)
9
+ except OSError as exc:
10
+ raise click.BadParameter(f"'{file.name}': {exc.strerror}") from exc
11
+ try:
12
+ file.open()
13
+ except click.FileError as exc:
14
+ raise click.BadParameter(exc.format_message()) from exc