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.
- schemathesis/__init__.py +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1760
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +50 -67
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +139 -23
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +478 -369
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -58
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -790
- schemathesis/cli/output/short.py +0 -44
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1234
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -570
- schemathesis/runner/events.py +0 -329
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -1035
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -323
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -199
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.6.dist-info/METADATA +0 -356
- schemathesis-3.25.6.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {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
|
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
|
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
|
16
|
-
|
17
|
-
from
|
18
|
-
from
|
19
|
-
from
|
20
|
-
from
|
21
|
-
from
|
22
|
-
from
|
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
|
-
"`--
|
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
|
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
|
-
|
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
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
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
|
-
|
91
|
+
rate_limit.parse_units(raw_value)
|
156
92
|
return raw_value
|
157
|
-
except
|
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("
|
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
|
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
|
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.
|
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
|
260
|
+
return reduce(operator.iadd, value, [])
|
342
261
|
|
343
262
|
|
344
|
-
def
|
345
|
-
|
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
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
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
|
357
|
-
|
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
|
365
|
-
if
|
366
|
-
|
367
|
-
|
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
|
-
|
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
|
schemathesis/cli/constants.py
CHANGED
@@ -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
|
-
|
12
|
-
|
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"
|
schemathesis/cli/core.py
ADDED
@@ -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"
|