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.
- schemathesis/__init__.py +27 -65
- schemathesis/auths.py +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- 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 +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -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} +59 -175
- schemathesis/cli/constants.py +5 -58
- 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} +37 -16
- 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 -7
- 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 +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- 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 +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- 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 +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -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} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- 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} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -936
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -56
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -104
- schemathesis/runner/impl/core.py +0 -1246
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/loaders.py +0 -708
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.7.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {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
|
10
|
+
from typing import Callable, Generator, Sequence
|
12
11
|
from urllib.parse import urlparse
|
13
12
|
|
14
13
|
import click
|
15
14
|
|
16
|
-
from
|
17
|
-
from
|
18
|
-
from
|
19
|
-
from
|
20
|
-
from
|
21
|
-
from
|
22
|
-
from
|
23
|
-
from
|
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
|
-
"`--
|
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
|
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
|
-
|
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
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|
-
|
91
|
+
rate_limit.parse_units(raw_value)
|
159
92
|
return raw_value
|
160
|
-
except
|
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("
|
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
|
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
|
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.
|
255
|
+
if feature.label in value or feature.is_env_var_set
|
340
256
|
]
|
341
257
|
|
342
258
|
|
343
|
-
def
|
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
|
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
|
402
|
-
return [
|
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
|
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
|
schemathesis/cli/constants.py
CHANGED
@@ -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
|
-
|
14
|
-
|
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"
|
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"
|
@@ -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
|