schemathesis 3.25.6__py3-none-any.whl → 4.0.0a1__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 (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1760
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{runner → engine/phases}/probes.py +50 -67
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +139 -23
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +478 -369
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -58
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -790
  156. schemathesis/cli/output/short.py +0 -44
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1234
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -570
  184. schemathesis/runner/events.py +0 -329
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -1035
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -323
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -199
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.6.dist-info/METADATA +0 -356
  219. schemathesis-3.25.6.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.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,124 +1,60 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import codecs
4
- import enum
4
+ import operator
5
5
  import os
6
+ import pathlib
6
7
  import re
7
- import traceback
8
8
  from contextlib import contextmanager
9
- from functools import partial
10
- from typing import Generator, TYPE_CHECKING, Callable
9
+ from functools import partial, reduce
10
+ from typing import Callable, Generator, Sequence
11
11
  from urllib.parse import urlparse
12
12
 
13
13
  import click
14
14
 
15
- from click.types import LazyFile # type: ignore
16
-
17
- from .. import exceptions, experimental, throttling
18
- from ..code_samples import CodeSampleStyle
19
- from ..exceptions import extract_nth_traceback
20
- from ..generation import DataGenerationMethod
21
- from ..constants import TRUE_VALUES, FALSE_VALUES
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 ..transports.headers import has_invalid_characters, is_latin_1_encodable
26
- from ..types import PathLike
27
- from .constants import DEFAULT_WORKERS
28
- from ..stateful import Stateful
29
-
30
- if TYPE_CHECKING:
31
- import hypothesis
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
32
23
 
33
24
  INVALID_DERANDOMIZE_MESSAGE = (
34
- "`--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."
35
26
  )
36
27
  MISSING_CASSETTE_PATH_ARGUMENT_MESSAGE = (
37
28
  "Missing argument, `--cassette-path` should be specified as well if you use `--cassette-preserve-exact-body-bytes`."
38
29
  )
39
- 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."
40
31
  FILE_DOES_NOT_EXIST_MESSAGE = "The specified file does not exist. Please provide a valid path to an existing file."
41
32
  INVALID_BASE_URL_MESSAGE = (
42
33
  "The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
43
34
  "Make sure it is a properly formatted URL."
44
35
  )
45
36
  MISSING_BASE_URL_MESSAGE = "The `--base-url` option is required when specifying a schema via a file."
46
- WSGI_DOCUMENTATION_URL = "https://schemathesis.readthedocs.io/en/stable/python.html#asgi-wsgi-support"
47
- APPLICATION_MISSING_MODULE_MESSAGE = f"""Unable to import application from {{module}}.
48
-
49
- The `--app` option should follow this format:
50
-
51
- module_path:variable_name
52
-
53
- - `module_path`: A path to an importable Python module.
54
- - `variable_name`: The name of the application variable within that module.
55
-
56
- Example: `st run --app=your_module:app ...`
57
-
58
- For details on working with WSGI applications, visit {WSGI_DOCUMENTATION_URL}"""
59
- APPLICATION_IMPORT_ERROR_MESSAGE = f"""An error occurred while loading the application from {{module}}.
60
-
61
- Traceback:
62
-
63
- {{traceback}}
64
-
65
- For details on working with WSGI applications, visit {WSGI_DOCUMENTATION_URL}"""
66
37
  MISSING_REQUEST_CERT_MESSAGE = "The `--request-cert` option must be specified if `--request-cert-key` is used."
67
38
 
68
39
 
69
- @enum.unique
70
- class SchemaInputKind(enum.Enum):
71
- """Kinds of SCHEMA input."""
72
-
73
- # Regular URL like https://example.schemathesis.io/openapi.json
74
- URL = 1
75
- # Local path
76
- PATH = 2
77
- # Relative path within a Python app
78
- APP_PATH = 3
79
- # A name for API created in Schemathesis.io
80
- NAME = 4
81
-
82
-
83
- def parse_schema_kind(schema: str, app: str | None) -> SchemaInputKind:
84
- """Detect what kind the input schema is."""
40
+ def validate_schema(schema: str, base_url: str | None) -> None:
85
41
  try:
86
42
  netloc = urlparse(schema).netloc
43
+ if netloc:
44
+ validate_url(schema)
45
+ return None
87
46
  except ValueError as exc:
88
47
  raise click.UsageError(INVALID_SCHEMA_MESSAGE) from exc
89
48
  if "\x00" in schema or not schema:
90
49
  raise click.UsageError(INVALID_SCHEMA_MESSAGE)
91
- if netloc:
92
- return SchemaInputKind.URL
93
- if file_exists(schema) or is_filename(schema):
94
- return SchemaInputKind.PATH
95
- if app is not None:
96
- return SchemaInputKind.APP_PATH
97
- # Assume NAME if it is not a URL or PATH or APP_PATH
98
- return SchemaInputKind.NAME
99
-
100
-
101
- def validate_schema(
102
- schema: str,
103
- kind: SchemaInputKind,
104
- *,
105
- base_url: str | None,
106
- dry_run: bool,
107
- app: str | None,
108
- api_name: str | None,
109
- ) -> None:
110
- if kind == SchemaInputKind.URL:
111
- validate_url(schema)
112
- if kind == SchemaInputKind.PATH:
113
- if app is None:
114
- if not file_exists(schema):
115
- raise click.UsageError(FILE_DOES_NOT_EXIST_MESSAGE)
116
- # Base URL is required if it is not a dry run
117
- if base_url is None and not dry_run:
118
- raise click.UsageError(MISSING_BASE_URL_MESSAGE)
119
- if kind == SchemaInputKind.NAME:
120
- if api_name is not None:
121
- 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)
122
58
 
123
59
 
124
60
  def validate_url(value: str) -> None:
@@ -152,43 +88,18 @@ def validate_rate_limit(ctx: click.core.Context, param: click.core.Parameter, ra
152
88
  if raw_value is None:
153
89
  return raw_value
154
90
  try:
155
- throttling.parse_units(raw_value)
91
+ rate_limit.parse_units(raw_value)
156
92
  return raw_value
157
- except exceptions.UsageError as exc:
93
+ except errors.IncorrectUsage as exc:
158
94
  raise click.UsageError(exc.args[0]) from exc
159
95
 
160
96
 
161
- def validate_app(ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None) -> str | None:
162
- if raw_value is None:
163
- return raw_value
164
- try:
165
- load_app(raw_value)
166
- # String is returned instead of an app because it might be passed to a subprocess
167
- # Since most app instances are not-transferable to another process, they are passed as strings and
168
- # imported in a subprocess
169
- return raw_value
170
- except Exception as exc:
171
- formatted_module_name = click.style(f"'{raw_value}'", bold=True)
172
- if isinstance(exc, ModuleNotFoundError):
173
- message = APPLICATION_MISSING_MODULE_MESSAGE.format(module=formatted_module_name)
174
- click.echo(message)
175
- else:
176
- trace = extract_nth_traceback(exc.__traceback__, 2)
177
- lines = traceback.format_exception(type(exc), exc, trace)
178
- traceback_message = "".join(lines).strip()
179
- message = APPLICATION_IMPORT_ERROR_MESSAGE.format(
180
- module=formatted_module_name, traceback=click.style(traceback_message, fg="red")
181
- )
182
- click.echo(message)
183
- raise click.exceptions.Exit(1) from None
184
-
185
-
186
97
  def validate_hypothesis_database(
187
98
  ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
188
99
  ) -> str | None:
189
100
  if raw_value is None:
190
101
  return raw_value
191
- if ctx.params.get("hypothesis_derandomize"):
102
+ if ctx.params.get("generation_deterministic"):
192
103
  raise click.UsageError(INVALID_DERANDOMIZE_MESSAGE)
193
104
  return raw_value
194
105
 
@@ -209,6 +120,24 @@ def validate_auth(
209
120
  return None
210
121
 
211
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
+
212
141
  def _validate_and_build_multiple_options(
213
142
  values: tuple[str, ...], name: str, callback: Callable[[str, str], None]
214
143
  ) -> dict[str, str]:
@@ -229,8 +158,14 @@ def _validate_and_build_multiple_options(
229
158
  return output
230
159
 
231
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
+
232
167
  def _validate_set_query(_: str, value: str) -> None:
233
- if is_illegal_surrogate(value):
168
+ if contains_unicode_surrogate_pair(value):
234
169
  raise click.BadParameter("Query parameter value should not contain surrogates.")
235
170
 
236
171
 
@@ -253,7 +188,7 @@ def validate_set_cookie(
253
188
 
254
189
 
255
190
  def _validate_set_path(_: str, value: str) -> None:
256
- if is_illegal_surrogate(value):
191
+ if contains_unicode_surrogate_pair(value):
257
192
  raise click.BadParameter("Path parameter value should not contain surrogates.")
258
193
 
259
194
 
@@ -311,80 +246,74 @@ def validate_preserve_exact_body_bytes(ctx: click.core.Context, param: click.cor
311
246
  return raw_value
312
247
 
313
248
 
314
- def convert_verbosity(
315
- ctx: click.core.Context, param: click.core.Parameter, value: str | None
316
- ) -> hypothesis.Verbosity | None:
317
- import hypothesis
318
-
319
- if value is None:
320
- return value
321
- return hypothesis.Verbosity[value]
322
-
323
-
324
- def convert_stateful(ctx: click.core.Context, param: click.core.Parameter, value: str) -> Stateful | None:
325
- if value == "none":
326
- return None
327
- return Stateful[value]
328
-
329
-
330
249
  def convert_experimental(
331
250
  ctx: click.core.Context, param: click.core.Parameter, value: tuple[str, ...]
332
251
  ) -> list[experimental.Experiment]:
333
252
  return [
334
253
  feature
335
254
  for feature in experimental.GLOBAL_EXPERIMENTS.available
336
- if feature.name in value or feature.is_env_var_set
255
+ if feature.label in value or feature.is_env_var_set
337
256
  ]
338
257
 
339
258
 
340
259
  def convert_checks(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
341
- return sum(value, [])
260
+ return reduce(operator.iadd, value, [])
342
261
 
343
262
 
344
- def convert_code_sample_style(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CodeSampleStyle:
345
- return CodeSampleStyle.from_str(value)
346
-
263
+ def convert_status_codes(
264
+ ctx: click.core.Context, param: click.core.Parameter, value: list[str] | None
265
+ ) -> list[str] | None:
266
+ if not value:
267
+ return value
347
268
 
348
- def convert_data_generation_method(
349
- ctx: click.core.Context, param: click.core.Parameter, value: str
350
- ) -> list[DataGenerationMethod]:
351
- if value == "all":
352
- return DataGenerationMethod.all()
353
- return [DataGenerationMethod[value]]
269
+ invalid = []
270
+
271
+ for code in value:
272
+ if len(code) != 3:
273
+ invalid.append(code)
274
+ continue
275
+
276
+ if code[0] not in {"1", "2", "3", "4", "5"}:
277
+ invalid.append(code)
278
+ continue
279
+
280
+ upper_code = code.upper()
281
+
282
+ if "X" in upper_code:
283
+ if (
284
+ upper_code[1:] == "XX"
285
+ or (upper_code[1] == "X" and upper_code[2].isdigit())
286
+ or (upper_code[1].isdigit() and upper_code[2] == "X")
287
+ ):
288
+ continue
289
+ else:
290
+ invalid.append(code)
291
+ continue
292
+
293
+ if not code.isnumeric():
294
+ invalid.append(code)
295
+
296
+ if invalid:
297
+ raise click.UsageError(
298
+ f"Invalid status code(s): {', '.join(invalid)}. "
299
+ "Use valid 3-digit codes between 100 and 599, "
300
+ "or wildcards (e.g., 2XX, 2X0, 20X), where X is a wildcard digit."
301
+ )
302
+ return value
354
303
 
355
304
 
356
- def _is_usable_dir(path: PathLike) -> bool:
357
- if os.path.isfile(path):
358
- path = os.path.dirname(path)
359
- while not os.path.exists(path):
360
- path = os.path.dirname(path)
361
- return os.path.isdir(path) and os.access(path, os.R_OK | os.W_OK | os.X_OK)
305
+ def convert_cassette_format(ctx: click.core.Context, param: click.core.Parameter, value: str) -> CassetteFormat:
306
+ return CassetteFormat.from_str(value)
362
307
 
363
308
 
364
- def convert_hosts_file(ctx: click.core.Context, param: click.core.Parameter, value: PathLike) -> PathLike:
365
- if not _is_usable_dir(value):
366
- path = get_temporary_hosts_file()
367
- click.secho(
368
- "WARNING: The provided hosts.toml file location is unusable - using a temporary file for this session. "
369
- f"path={str(value)!r}",
370
- fg="yellow",
371
- )
372
- return path
373
- return value
309
+ def convert_generation_mode(ctx: click.core.Context, param: click.core.Parameter, value: str) -> list[GenerationMode]:
310
+ if value == "all":
311
+ return GenerationMode.all()
312
+ return [GenerationMode(value)]
374
313
 
375
314
 
376
315
  def convert_boolean_string(ctx: click.core.Context, param: click.core.Parameter, value: str) -> str | bool:
377
- if value.lower() in TRUE_VALUES:
378
- return True
379
- if value.lower() in FALSE_VALUES:
380
- return False
381
- return value
382
-
383
-
384
- def convert_report(ctx: click.core.Context, param: click.core.Option, value: LazyFile) -> LazyFile:
385
- if param.resolve_envvar_value(ctx) is not None and value.lower() in TRUE_VALUES:
386
- value = param.flag_value
387
- return value
316
+ return string_to_boolean(value)
388
317
 
389
318
 
390
319
  @contextmanager
@@ -1,55 +1,8 @@
1
- from __future__ import annotations
2
- from enum import IntEnum, unique
3
- from typing import TYPE_CHECKING
4
-
5
- if TYPE_CHECKING:
6
- import hypothesis
7
1
  MIN_WORKERS = 1
8
2
  DEFAULT_WORKERS = MIN_WORKERS
9
3
  MAX_WORKERS = 64
10
-
11
- # Importing Hypothesis is expensive, hence we re-create the enums we need in CLI commands definitions
12
- # Hypothesis is stable, hence it should not be a problem and adding new variants should not be automatic
13
-
14
-
15
- @unique
16
- class Phase(IntEnum):
17
- explicit = 0 #: controls whether explicit examples are run.
18
- reuse = 1 #: controls whether previous examples will be reused.
19
- generate = 2 #: controls whether new examples will be generated.
20
- target = 3 #: controls whether examples will be mutated for targeting.
21
- shrink = 4 #: controls whether examples will be shrunk.
22
- # The `explain` phase is not supported
23
-
24
- def as_hypothesis(self) -> hypothesis.Phase:
25
- from hypothesis import Phase
26
-
27
- return Phase[self.name]
28
-
29
- @staticmethod
30
- def filter_from_all(variants: list[Phase]) -> list[hypothesis.Phase]:
31
- from hypothesis import Phase
32
-
33
- return list(set(Phase) - {Phase.explain} - set(variants))
34
-
35
-
36
- @unique
37
- class HealthCheck(IntEnum):
38
- # We remove not relevant checks
39
- data_too_large = 1
40
- filter_too_much = 2
41
- too_slow = 3
42
- large_base_example = 7
43
-
44
- def as_hypothesis(self) -> hypothesis.HealthCheck:
45
- from hypothesis import HealthCheck
46
-
47
- return HealthCheck[self.name]
48
-
49
-
50
- @unique
51
- class Verbosity(IntEnum):
52
- quiet = 0
53
- normal = 1
54
- verbose = 2
55
- 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"