schemathesis 3.39.16__py3-none-any.whl → 4.0.0__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 +41 -79
- schemathesis/auths.py +111 -122
- schemathesis/checks.py +169 -60
- schemathesis/cli/__init__.py +15 -2117
- schemathesis/cli/commands/__init__.py +85 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +590 -0
- schemathesis/cli/commands/run/context.py +204 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -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 +474 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
- schemathesis/cli/commands/run/handlers/output.py +1628 -0
- schemathesis/cli/commands/run/loaders.py +114 -0
- schemathesis/cli/commands/run/validation.py +246 -0
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +84 -0
- schemathesis/cli/{options.py → ext/options.py} +36 -34
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +527 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +67 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +459 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -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/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -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 +223 -0
- schemathesis/core/validation.py +54 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +118 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +169 -0
- schemathesis/engine/errors.py +464 -0
- schemathesis/engine/events.py +258 -0
- schemathesis/engine/phases/__init__.py +88 -0
- schemathesis/{runner → engine/phases}/probes.py +52 -68
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +356 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +212 -0
- schemathesis/engine/phases/unit/_executor.py +416 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +247 -0
- schemathesis/errors.py +43 -0
- schemathesis/filters.py +17 -98
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +317 -0
- schemathesis/generation/coverage.py +282 -175
- schemathesis/generation/hypothesis/__init__.py +36 -0
- schemathesis/generation/hypothesis/builder.py +800 -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/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +116 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +278 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +284 -0
- schemathesis/hooks.py +80 -101
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +455 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +313 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +281 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -273
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +42 -6
- schemathesis/specs/graphql/schemas.py +141 -137
- 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 +142 -156
- schemathesis/specs/openapi/checks.py +368 -257
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +23 -21
- schemathesis/specs/openapi/expressions/__init__.py +31 -19
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/lexer.py +1 -1
- schemathesis/specs/openapi/expressions/nodes.py +36 -41
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/formats.py +35 -7
- schemathesis/specs/openapi/media_types.py +53 -12
- schemathesis/specs/openapi/negative/__init__.py +7 -4
- schemathesis/specs/openapi/negative/mutations.py +6 -5
- schemathesis/specs/openapi/parameters.py +7 -10
- schemathesis/specs/openapi/patterns.py +94 -31
- schemathesis/specs/openapi/references.py +12 -53
- schemathesis/specs/openapi/schemas.py +233 -307
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +12 -6
- schemathesis/specs/openapi/stateful/__init__.py +268 -133
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/links.py +209 -0
- schemathesis/transport/__init__.py +142 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +124 -0
- schemathesis/transport/requests.py +244 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -11
- schemathesis/transport/wsgi.py +171 -0
- schemathesis-4.0.0.dist-info/METADATA +204 -0
- schemathesis-4.0.0.dist-info/RECORD +164 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -717
- schemathesis/_override.py +0 -50
- schemathesis/_patches.py +0 -21
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/callbacks.py +0 -466
- schemathesis/cli/cassettes.py +0 -561
- 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 -920
- 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 -54
- schemathesis/contrib/__init__.py +0 -11
- schemathesis/contrib/openapi/__init__.py +0 -11
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
- 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/experimental/__init__.py +0 -109
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -284
- 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 -86
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -37
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/output.py +0 -68
- 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 -88
- schemathesis/runner/impl/core.py +0 -1280
- 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/links.py +0 -389
- schemathesis/specs/openapi/loaders.py +0 -707
- 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/state_machine.py +0 -328
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -369
- 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.16.dist-info/METADATA +0 -293
- schemathesis-3.39.16.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.16.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,114 @@
|
|
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 os
|
10
|
+
import warnings
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable
|
12
|
+
|
13
|
+
from schemathesis import graphql, openapi
|
14
|
+
from schemathesis.config import ProjectConfig
|
15
|
+
from schemathesis.core.errors import LoaderError, LoaderErrorKind
|
16
|
+
from schemathesis.core.fs import file_exists
|
17
|
+
|
18
|
+
if TYPE_CHECKING:
|
19
|
+
from schemathesis.schemas import BaseSchema
|
20
|
+
|
21
|
+
Loader = Callable[["ProjectConfig"], "BaseSchema"]
|
22
|
+
|
23
|
+
|
24
|
+
def load_schema(location: str, config: ProjectConfig) -> BaseSchema:
|
25
|
+
"""Load API schema automatically based on the provided configuration."""
|
26
|
+
if is_probably_graphql(location):
|
27
|
+
# Try GraphQL first, then fallback to Open API
|
28
|
+
return _try_load_schema(location, config, graphql, openapi)
|
29
|
+
# Try Open API first, then fallback to GraphQL
|
30
|
+
return _try_load_schema(location, config, openapi, graphql)
|
31
|
+
|
32
|
+
|
33
|
+
def should_try_more(exc: LoaderError) -> bool:
|
34
|
+
"""Determine if alternative schema loading should be attempted."""
|
35
|
+
import requests
|
36
|
+
from yaml.reader import ReaderError
|
37
|
+
|
38
|
+
if isinstance(exc.__cause__, ReaderError) and "characters are not allowed" in str(exc.__cause__):
|
39
|
+
return False
|
40
|
+
|
41
|
+
# We should not try other loaders for cases when we can't even establish connection
|
42
|
+
return not isinstance(exc.__cause__, requests.exceptions.ConnectionError) and exc.kind not in (
|
43
|
+
LoaderErrorKind.OPEN_API_INVALID_SCHEMA,
|
44
|
+
LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION,
|
45
|
+
LoaderErrorKind.OPEN_API_UNSUPPORTED_VERSION,
|
46
|
+
)
|
47
|
+
|
48
|
+
|
49
|
+
def detect_loader(location: str, module: Any) -> Callable:
|
50
|
+
"""Detect API schema loader."""
|
51
|
+
if file_exists(location):
|
52
|
+
return module.from_path # type: ignore
|
53
|
+
return module.from_url # type: ignore
|
54
|
+
|
55
|
+
|
56
|
+
def _try_load_schema(location: str, config: ProjectConfig, first_module: Any, second_module: Any) -> BaseSchema:
|
57
|
+
"""Try to load schema with fallback option."""
|
58
|
+
from urllib3.exceptions import InsecureRequestWarning
|
59
|
+
|
60
|
+
with warnings.catch_warnings():
|
61
|
+
warnings.simplefilter("ignore", InsecureRequestWarning)
|
62
|
+
try:
|
63
|
+
return _load_schema(location, config, first_module)
|
64
|
+
except LoaderError as exc:
|
65
|
+
# If this was the OpenAPI loader on an explicit OpenAPI file, don't fallback
|
66
|
+
if first_module is openapi and is_openapi_file(location):
|
67
|
+
raise exc
|
68
|
+
if should_try_more(exc):
|
69
|
+
try:
|
70
|
+
return _load_schema(location, config, second_module)
|
71
|
+
except Exception as second_exc:
|
72
|
+
if is_specific_exception(second_exc):
|
73
|
+
raise second_exc
|
74
|
+
# Re-raise the original error
|
75
|
+
raise exc
|
76
|
+
|
77
|
+
|
78
|
+
def _load_schema(location: str, config: ProjectConfig, module: Any) -> BaseSchema:
|
79
|
+
"""Unified schema loader for both GraphQL and OpenAPI."""
|
80
|
+
loader = detect_loader(location, module)
|
81
|
+
|
82
|
+
kwargs: dict = {}
|
83
|
+
if loader is module.from_url:
|
84
|
+
if config.wait_for_schema is not None:
|
85
|
+
kwargs["wait_for_schema"] = config.wait_for_schema
|
86
|
+
kwargs["verify"] = config.tls_verify
|
87
|
+
request_cert = config.request_cert_for()
|
88
|
+
if request_cert:
|
89
|
+
kwargs["cert"] = request_cert
|
90
|
+
auth = config.auth_for()
|
91
|
+
if auth is not None:
|
92
|
+
kwargs["auth"] = auth
|
93
|
+
|
94
|
+
return loader(location, config=config._parent, **kwargs)
|
95
|
+
|
96
|
+
|
97
|
+
def is_specific_exception(exc: Exception) -> bool:
|
98
|
+
"""Determine if alternative schema loading should be attempted."""
|
99
|
+
return (
|
100
|
+
isinstance(exc, LoaderError)
|
101
|
+
and exc.kind == LoaderErrorKind.GRAPHQL_INVALID_SCHEMA
|
102
|
+
# In some cases it is not clear that the schema is even supposed to be GraphQL, e.g. an empty input
|
103
|
+
and "Syntax Error: Unexpected <EOF>." not in exc.extras
|
104
|
+
)
|
105
|
+
|
106
|
+
|
107
|
+
def is_probably_graphql(location: str) -> bool:
|
108
|
+
"""Detect whether it is likely that the given location is a GraphQL endpoint."""
|
109
|
+
return location.endswith(("/graphql", "/graphql/", ".graphql", ".gql"))
|
110
|
+
|
111
|
+
|
112
|
+
def is_openapi_file(location: str) -> bool:
|
113
|
+
name = os.path.basename(location).lower()
|
114
|
+
return any(name == f"{base}{ext}" for base in ("openapi", "swagger") for ext in (".json", ".yaml", ".yml"))
|
@@ -0,0 +1,246 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import codecs
|
4
|
+
import operator
|
5
|
+
import pathlib
|
6
|
+
from contextlib import contextmanager
|
7
|
+
from functools import reduce
|
8
|
+
from typing import Callable, Generator
|
9
|
+
from urllib.parse import urlparse
|
10
|
+
|
11
|
+
import click
|
12
|
+
|
13
|
+
from schemathesis.cli.ext.options import CsvEnumChoice
|
14
|
+
from schemathesis.config import ReportFormat, SchemathesisWarning, get_workers_count
|
15
|
+
from schemathesis.core import errors, rate_limit, string_to_boolean
|
16
|
+
from schemathesis.core.fs import file_exists
|
17
|
+
from schemathesis.core.validation import has_invalid_characters, is_latin_1_encodable
|
18
|
+
from schemathesis.filters import expression_to_filter_function
|
19
|
+
from schemathesis.generation import GenerationMode
|
20
|
+
from schemathesis.generation.metrics import MetricFunction
|
21
|
+
|
22
|
+
INVALID_DERANDOMIZE_MESSAGE = (
|
23
|
+
"`--generation-deterministic` implies no database, so passing `--generation-database` too is invalid."
|
24
|
+
)
|
25
|
+
INVALID_REPORT_USAGE = (
|
26
|
+
"Can't use `--report-preserve-bytes` without enabling cassette formats. "
|
27
|
+
"Enable VCR or HAR format with `--report=vcr`, `--report-vcr-path`, "
|
28
|
+
"`--report=har`, or `--report-har-path`"
|
29
|
+
)
|
30
|
+
INVALID_SCHEMA_MESSAGE = "Invalid SCHEMA, must be a valid URL or file path."
|
31
|
+
FILE_DOES_NOT_EXIST_MESSAGE = "The specified file does not exist. Please provide a valid path to an existing file."
|
32
|
+
INVALID_BASE_URL_MESSAGE = (
|
33
|
+
"The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
|
34
|
+
"Make sure it is a properly formatted URL."
|
35
|
+
)
|
36
|
+
MISSING_REQUEST_CERT_MESSAGE = "The `--request-cert` option must be specified if `--request-cert-key` is used."
|
37
|
+
|
38
|
+
|
39
|
+
def validate_schema_location(ctx: click.core.Context, param: click.core.Parameter, location: str) -> str:
|
40
|
+
try:
|
41
|
+
netloc = urlparse(location).netloc
|
42
|
+
if netloc:
|
43
|
+
validate_url(location)
|
44
|
+
return location
|
45
|
+
except ValueError as exc:
|
46
|
+
raise click.UsageError(INVALID_SCHEMA_MESSAGE) from exc
|
47
|
+
if "\x00" in location or not location:
|
48
|
+
raise click.UsageError(INVALID_SCHEMA_MESSAGE)
|
49
|
+
exists = file_exists(location)
|
50
|
+
if exists or bool(pathlib.Path(location).suffix):
|
51
|
+
if not exists:
|
52
|
+
raise click.UsageError(FILE_DOES_NOT_EXIST_MESSAGE)
|
53
|
+
return location
|
54
|
+
raise click.UsageError(INVALID_SCHEMA_MESSAGE)
|
55
|
+
|
56
|
+
|
57
|
+
def validate_url(value: str) -> None:
|
58
|
+
from requests import PreparedRequest, RequestException
|
59
|
+
|
60
|
+
try:
|
61
|
+
PreparedRequest().prepare_url(value, {}) # type: ignore
|
62
|
+
except RequestException as exc:
|
63
|
+
raise click.UsageError(INVALID_SCHEMA_MESSAGE) from exc
|
64
|
+
|
65
|
+
|
66
|
+
def validate_base_url(ctx: click.core.Context, param: click.core.Parameter, raw_value: str) -> str:
|
67
|
+
try:
|
68
|
+
netloc = urlparse(raw_value).netloc
|
69
|
+
except ValueError as exc:
|
70
|
+
raise click.UsageError(INVALID_BASE_URL_MESSAGE) from exc
|
71
|
+
if raw_value and not netloc:
|
72
|
+
raise click.UsageError(INVALID_BASE_URL_MESSAGE)
|
73
|
+
return raw_value
|
74
|
+
|
75
|
+
|
76
|
+
def validate_generation_codec(ctx: click.core.Context, param: click.core.Parameter, raw_value: str) -> str:
|
77
|
+
try:
|
78
|
+
codecs.getencoder(raw_value)
|
79
|
+
except LookupError as exc:
|
80
|
+
raise click.UsageError(f"Codec `{raw_value}` is unknown") from exc
|
81
|
+
return raw_value
|
82
|
+
|
83
|
+
|
84
|
+
def validate_rate_limit(ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None) -> str | None:
|
85
|
+
if raw_value is None:
|
86
|
+
return raw_value
|
87
|
+
try:
|
88
|
+
rate_limit.parse_units(raw_value)
|
89
|
+
return raw_value
|
90
|
+
except errors.IncorrectUsage as exc:
|
91
|
+
raise click.UsageError(exc.args[0]) from exc
|
92
|
+
|
93
|
+
|
94
|
+
def validate_hypothesis_database(
|
95
|
+
ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
|
96
|
+
) -> str | None:
|
97
|
+
if raw_value is None:
|
98
|
+
return raw_value
|
99
|
+
if ctx.params.get("generation_deterministic"):
|
100
|
+
raise click.UsageError(INVALID_DERANDOMIZE_MESSAGE)
|
101
|
+
return raw_value
|
102
|
+
|
103
|
+
|
104
|
+
def validate_auth(
|
105
|
+
ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
|
106
|
+
) -> tuple[str, str] | None:
|
107
|
+
if raw_value is not None:
|
108
|
+
with reraise_format_error(raw_value):
|
109
|
+
user, password = tuple(raw_value.split(":"))
|
110
|
+
if not user:
|
111
|
+
raise click.BadParameter("Username should not be empty.")
|
112
|
+
if not is_latin_1_encodable(user):
|
113
|
+
raise click.BadParameter("Username should be latin-1 encodable.")
|
114
|
+
if not is_latin_1_encodable(password):
|
115
|
+
raise click.BadParameter("Password should be latin-1 encodable.")
|
116
|
+
return user, password
|
117
|
+
return None
|
118
|
+
|
119
|
+
|
120
|
+
def validate_auth_overlap(auth: tuple[str, str] | None, headers: dict[str, str]) -> None:
|
121
|
+
auth_is_set = auth is not None
|
122
|
+
header_is_set = "authorization" in {header.lower() for header in headers}
|
123
|
+
if len([is_set for is_set in (auth_is_set, header_is_set) if is_set]) > 1:
|
124
|
+
message = "The "
|
125
|
+
used = []
|
126
|
+
if auth_is_set:
|
127
|
+
used.append("`--auth`")
|
128
|
+
if header_is_set:
|
129
|
+
used.append("`--header`")
|
130
|
+
message += " and ".join(used)
|
131
|
+
message += " options were both used to set the 'Authorization' header, which is not permitted."
|
132
|
+
raise click.BadParameter(message)
|
133
|
+
|
134
|
+
|
135
|
+
def validate_filter_expression(
|
136
|
+
ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
|
137
|
+
) -> Callable | None:
|
138
|
+
if raw_value:
|
139
|
+
try:
|
140
|
+
return expression_to_filter_function(raw_value)
|
141
|
+
except ValueError:
|
142
|
+
arg_name = param.opts[0]
|
143
|
+
raise click.UsageError(f"Invalid expression for {arg_name}: {raw_value}") from None
|
144
|
+
return None
|
145
|
+
|
146
|
+
|
147
|
+
def _validate_header(key: str, value: str, where: str) -> None:
|
148
|
+
if not key:
|
149
|
+
raise click.BadParameter(f"{where} name should not be empty.")
|
150
|
+
if not is_latin_1_encodable(key):
|
151
|
+
raise click.BadParameter(f"{where} name should be latin-1 encodable.")
|
152
|
+
if not is_latin_1_encodable(value):
|
153
|
+
raise click.BadParameter(f"{where} value should be latin-1 encodable.")
|
154
|
+
if has_invalid_characters(key, value):
|
155
|
+
raise click.BadParameter(f"Invalid return character or leading space in {where.lower()}.")
|
156
|
+
|
157
|
+
|
158
|
+
def validate_headers(
|
159
|
+
ctx: click.core.Context, param: click.core.Parameter, raw_value: tuple[str, ...]
|
160
|
+
) -> dict[str, str]:
|
161
|
+
headers = {}
|
162
|
+
for header in raw_value:
|
163
|
+
with reraise_format_error(header):
|
164
|
+
key, value = header.split(":", maxsplit=1)
|
165
|
+
value = value.lstrip()
|
166
|
+
key = key.strip()
|
167
|
+
_validate_header(key, value, where="Header")
|
168
|
+
headers[key] = value
|
169
|
+
return headers
|
170
|
+
|
171
|
+
|
172
|
+
def validate_request_cert_key(
|
173
|
+
ctx: click.core.Context, param: click.core.Parameter, raw_value: str | None
|
174
|
+
) -> str | None:
|
175
|
+
if raw_value is not None and "request_cert" not in ctx.params:
|
176
|
+
raise click.UsageError(MISSING_REQUEST_CERT_MESSAGE)
|
177
|
+
return raw_value
|
178
|
+
|
179
|
+
|
180
|
+
def validate_preserve_bytes(ctx: click.core.Context, param: click.core.Parameter, raw_value: bool) -> bool:
|
181
|
+
if not raw_value:
|
182
|
+
return False
|
183
|
+
|
184
|
+
report_formats = ctx.params.get("report_formats", []) or []
|
185
|
+
vcr_enabled = ReportFormat.VCR in report_formats or ctx.params.get("report_vcr_path")
|
186
|
+
har_enabled = ReportFormat.HAR in report_formats or ctx.params.get("report_har_path")
|
187
|
+
|
188
|
+
if not (vcr_enabled or har_enabled):
|
189
|
+
raise click.UsageError(INVALID_REPORT_USAGE)
|
190
|
+
|
191
|
+
return True
|
192
|
+
|
193
|
+
|
194
|
+
def reduce_list(
|
195
|
+
ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]] | None
|
196
|
+
) -> list[str] | None:
|
197
|
+
if not value:
|
198
|
+
return None
|
199
|
+
return reduce(operator.iadd, value, [])
|
200
|
+
|
201
|
+
|
202
|
+
def convert_maximize(
|
203
|
+
ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]
|
204
|
+
) -> list[MetricFunction]:
|
205
|
+
from schemathesis.generation.metrics import METRICS
|
206
|
+
|
207
|
+
names: list[str] = reduce(operator.iadd, value, [])
|
208
|
+
return METRICS.get_by_names(names)
|
209
|
+
|
210
|
+
|
211
|
+
def convert_generation_mode(ctx: click.core.Context, param: click.core.Parameter, value: str) -> list[GenerationMode]:
|
212
|
+
if value == "all":
|
213
|
+
return list(GenerationMode)
|
214
|
+
return [GenerationMode(value)]
|
215
|
+
|
216
|
+
|
217
|
+
def convert_boolean_string(ctx: click.core.Context, param: click.core.Parameter, value: str) -> str | bool:
|
218
|
+
return string_to_boolean(value)
|
219
|
+
|
220
|
+
|
221
|
+
@contextmanager
|
222
|
+
def reraise_format_error(raw_value: str) -> Generator[None, None, None]:
|
223
|
+
try:
|
224
|
+
yield
|
225
|
+
except ValueError as exc:
|
226
|
+
raise click.BadParameter(f"Expected KEY:VALUE format, received {raw_value}.") from exc
|
227
|
+
|
228
|
+
|
229
|
+
def convert_workers(ctx: click.core.Context, param: click.core.Parameter, value: str) -> int:
|
230
|
+
if value == "auto":
|
231
|
+
return get_workers_count()
|
232
|
+
return int(value)
|
233
|
+
|
234
|
+
|
235
|
+
WARNINGS_CHOICE = CsvEnumChoice(SchemathesisWarning)
|
236
|
+
|
237
|
+
|
238
|
+
def validate_warnings(
|
239
|
+
ctx: click.core.Context, param: click.core.Parameter, value: str | None
|
240
|
+
) -> bool | None | list[SchemathesisWarning]:
|
241
|
+
if value is None:
|
242
|
+
return None
|
243
|
+
boolean = string_to_boolean(value)
|
244
|
+
if isinstance(boolean, bool):
|
245
|
+
return boolean
|
246
|
+
return WARNINGS_CHOICE.convert(value, param, ctx) # type: ignore[return-value]
|
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/guides/extending/"
|
schemathesis/cli/core.py
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import os
|
4
|
+
import shutil
|
5
|
+
|
6
|
+
import click
|
7
|
+
|
8
|
+
|
9
|
+
def get_terminal_width() -> int:
|
10
|
+
# Some CI/CD providers (e.g. CircleCI) return a (0, 0) terminal size so provide a default
|
11
|
+
return shutil.get_terminal_size((80, 24)).columns
|
12
|
+
|
13
|
+
|
14
|
+
def ensure_color(ctx: click.Context, color: bool | None) -> None:
|
15
|
+
if color:
|
16
|
+
ctx.color = True
|
17
|
+
elif color is False or "NO_COLOR" in os.environ:
|
18
|
+
ctx.color = False
|
19
|
+
os.environ["NO_COLOR"] = "1"
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
|
3
|
+
import click
|
4
|
+
|
5
|
+
from schemathesis.core.fs import ensure_parent
|
6
|
+
|
7
|
+
|
8
|
+
def open_file(file: Path) -> None:
|
9
|
+
try:
|
10
|
+
ensure_parent(file, fail_silently=False)
|
11
|
+
except OSError as exc:
|
12
|
+
raise click.BadParameter(f"'{file.name}': {exc.strerror}") from exc
|
13
|
+
try:
|
14
|
+
file.open("w", encoding="utf-8")
|
15
|
+
except OSError as exc:
|
16
|
+
raise click.BadParameter(f"Could not open file {file.name}: {exc}") from exc
|
@@ -0,0 +1,84 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import textwrap
|
4
|
+
from typing import Any, Callable
|
5
|
+
|
6
|
+
import click
|
7
|
+
|
8
|
+
GROUPS: dict[str, OptionGroup] = {}
|
9
|
+
|
10
|
+
|
11
|
+
class OptionGroup:
|
12
|
+
__slots__ = ("order", "name", "description", "options")
|
13
|
+
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
name: str,
|
17
|
+
*,
|
18
|
+
order: int | None = None,
|
19
|
+
description: str | None = None,
|
20
|
+
):
|
21
|
+
self.name = name
|
22
|
+
self.description = description
|
23
|
+
self.order = order if order is not None else len(GROUPS) * 100
|
24
|
+
self.options: list[tuple[str, str]] = []
|
25
|
+
|
26
|
+
|
27
|
+
class CommandWithGroupedOptions(click.Command):
|
28
|
+
def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
29
|
+
# Collect options into groups or ungrouped list
|
30
|
+
for param in self.get_params(ctx):
|
31
|
+
rv = param.get_help_record(ctx)
|
32
|
+
if rv is not None:
|
33
|
+
option_repr, message = rv
|
34
|
+
if isinstance(param.type, click.Choice):
|
35
|
+
message += (
|
36
|
+
getattr(param.type, "choices_repr", None)
|
37
|
+
or f" [possible values: {', '.join(param.type.choices)}]"
|
38
|
+
)
|
39
|
+
|
40
|
+
if isinstance(param, GroupedOption) and param.group is not None:
|
41
|
+
group = GROUPS.get(param.group)
|
42
|
+
if group:
|
43
|
+
group.options.append((option_repr, message))
|
44
|
+
else:
|
45
|
+
GROUPS["Global options"].options.append((option_repr, message))
|
46
|
+
|
47
|
+
groups = sorted(GROUPS.values(), key=lambda g: g.order)
|
48
|
+
# Format each group
|
49
|
+
for group in groups:
|
50
|
+
with formatter.section(group.name):
|
51
|
+
if group.description:
|
52
|
+
formatter.write(textwrap.indent(group.description, " " * formatter.current_indent))
|
53
|
+
formatter.write("\n\n")
|
54
|
+
|
55
|
+
if group.options:
|
56
|
+
formatter.write_dl(group.options)
|
57
|
+
|
58
|
+
|
59
|
+
class GroupedOption(click.Option):
|
60
|
+
def __init__(self, *args: Any, group: str | None = None, **kwargs: Any):
|
61
|
+
super().__init__(*args, **kwargs)
|
62
|
+
self.group = group
|
63
|
+
|
64
|
+
|
65
|
+
def group(
|
66
|
+
name: str,
|
67
|
+
*,
|
68
|
+
description: str | None = None,
|
69
|
+
) -> Callable:
|
70
|
+
GROUPS[name] = OptionGroup(name, description=description)
|
71
|
+
|
72
|
+
def _inner(cmd: Callable) -> Callable:
|
73
|
+
for param in reversed(cmd.__click_params__): # type: ignore[attr-defined]
|
74
|
+
if not isinstance(param, GroupedOption) or param.group is not None:
|
75
|
+
break
|
76
|
+
param.group = name
|
77
|
+
return cmd
|
78
|
+
|
79
|
+
return _inner
|
80
|
+
|
81
|
+
|
82
|
+
def grouped_option(*args: Any, **kwargs: Any) -> Callable:
|
83
|
+
kwargs.setdefault("cls", GroupedOption)
|
84
|
+
return click.option(*args, **kwargs)
|
@@ -1,15 +1,11 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from
|
3
|
+
from enum import Enum
|
4
|
+
from typing import Any, NoReturn
|
4
5
|
|
5
6
|
import click
|
6
7
|
|
7
|
-
from
|
8
|
-
|
9
|
-
if TYPE_CHECKING:
|
10
|
-
from enum import Enum
|
11
|
-
|
12
|
-
from ..types import NotSet
|
8
|
+
from schemathesis.core.registries import Registry
|
13
9
|
|
14
10
|
|
15
11
|
class CustomHelpMessageChoice(click.Choice):
|
@@ -25,7 +21,7 @@ class CustomHelpMessageChoice(click.Choice):
|
|
25
21
|
|
26
22
|
class BaseCsvChoice(click.Choice):
|
27
23
|
def parse_value(self, value: str) -> tuple[list[str], set[str]]:
|
28
|
-
selected = [item for item in value.split(",") if item]
|
24
|
+
selected = [item.strip() for item in value.split(",") if item.strip()]
|
29
25
|
if not self.case_sensitive:
|
30
26
|
invalid_options = {
|
31
27
|
item for item in selected if item.upper() not in {choice.upper() for choice in self.choices}
|
@@ -34,50 +30,56 @@ class BaseCsvChoice(click.Choice):
|
|
34
30
|
invalid_options = set(selected) - set(self.choices)
|
35
31
|
return selected, invalid_options
|
36
32
|
|
37
|
-
def fail_on_invalid_options(self, invalid_options: set[str], selected: list[str]) -> NoReturn:
|
33
|
+
def fail_on_invalid_options(self, invalid_options: set[str], selected: list[str]) -> NoReturn: # type: ignore[misc]
|
38
34
|
# Sort to keep the error output consistent with the passed values
|
39
35
|
sorted_options = ", ".join(sorted(invalid_options, key=selected.index))
|
40
36
|
available_options = ", ".join(self.choices)
|
41
37
|
self.fail(f"invalid choice(s): {sorted_options}. Choose from {available_options}.")
|
42
38
|
|
43
39
|
|
44
|
-
class
|
45
|
-
def __init__(self, choices: type[Enum]):
|
46
|
-
self.enum = choices
|
47
|
-
super().__init__(tuple(el.name for el in choices))
|
48
|
-
|
40
|
+
class CsvChoice(BaseCsvChoice):
|
49
41
|
def convert( # type: ignore[return]
|
50
42
|
self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
|
51
|
-
) -> list[
|
43
|
+
) -> list[str]:
|
52
44
|
selected, invalid_options = self.parse_value(value)
|
53
45
|
if not invalid_options and selected:
|
54
|
-
return
|
46
|
+
return selected
|
55
47
|
self.fail_on_invalid_options(invalid_options, selected)
|
56
48
|
|
57
49
|
|
58
|
-
class
|
59
|
-
def
|
50
|
+
class CsvEnumChoice(BaseCsvChoice):
|
51
|
+
def __init__(self, choices: type[Enum], case_sensitive: bool = False):
|
52
|
+
self.enum = choices
|
53
|
+
super().__init__(tuple(el.name.lower() for el in choices), case_sensitive=case_sensitive)
|
54
|
+
|
55
|
+
def convert(self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None) -> list[Enum]:
|
60
56
|
selected, invalid_options = self.parse_value(value)
|
61
57
|
if not invalid_options and selected:
|
62
|
-
|
58
|
+
# Match case-insensitively to find the correct enum
|
59
|
+
return [
|
60
|
+
next(enum_value for enum_value in self.enum if enum_value.value.upper() == item.upper())
|
61
|
+
for item in selected
|
62
|
+
]
|
63
63
|
self.fail_on_invalid_options(invalid_options, selected)
|
64
64
|
|
65
65
|
|
66
|
-
class
|
67
|
-
def
|
68
|
-
self
|
69
|
-
|
70
|
-
|
66
|
+
class RegistryChoice(BaseCsvChoice):
|
67
|
+
def __init__(self, registry: Registry, with_all: bool = False) -> None:
|
68
|
+
self.registry = registry
|
69
|
+
self.case_sensitive = True
|
70
|
+
self.with_all = with_all
|
71
71
|
|
72
|
+
@property
|
73
|
+
def choices(self) -> list[str]:
|
74
|
+
choices = self.registry.get_all_names()
|
75
|
+
if self.with_all:
|
76
|
+
choices.append("all")
|
77
|
+
return choices
|
72
78
|
|
73
|
-
|
74
|
-
def convert( # type: ignore
|
79
|
+
def convert( # type: ignore[return]
|
75
80
|
self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
|
76
|
-
) ->
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
return super().convert(value, param, ctx)
|
82
|
-
except ValueError:
|
83
|
-
self.fail(f"{value} is not a valid integer or None.", param, ctx)
|
81
|
+
) -> list[str]:
|
82
|
+
selected, invalid_options = self.parse_value(value)
|
83
|
+
if not invalid_options and selected:
|
84
|
+
return selected
|
85
|
+
self.fail_on_invalid_options(invalid_options, selected)
|