schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1219
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -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 +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +682 -257
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +26 -2
- schemathesis/specs/graphql/scalars.py +77 -12
- schemathesis/specs/graphql/schemas.py +367 -148
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +555 -318
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +748 -82
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +93 -73
- schemathesis/specs/openapi/negative/mutations.py +294 -103
- schemathesis/specs/openapi/negative/utils.py +0 -9
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +647 -666
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +403 -68
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -57
- schemathesis/_hypothesis.py +0 -123
- schemathesis/auth.py +0 -214
- schemathesis/cli/callbacks.py +0 -240
- schemathesis/cli/cassettes.py +0 -351
- schemathesis/cli/context.py +0 -38
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -70
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -521
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -88
- schemathesis/exceptions.py +0 -257
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -251
- schemathesis/failures.py +0 -145
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/graphql.py +0 -5
- schemathesis/internal.py +0 -6
- schemathesis/lazy.py +0 -301
- schemathesis/models.py +0 -1113
- schemathesis/parameters.py +0 -91
- schemathesis/runner/__init__.py +0 -470
- schemathesis/runner/events.py +0 -242
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -791
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -206
- schemathesis/serializers.py +0 -253
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -10
- schemathesis/service/client.py +0 -62
- schemathesis/service/constants.py +0 -25
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -46
- schemathesis/service/hosts.py +0 -74
- schemathesis/service/metadata.py +0 -42
- schemathesis/service/models.py +0 -21
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/worker.py +0 -39
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -303
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -430
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -358
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -475
- schemathesis-3.15.4.dist-info/METADATA +0 -202
- schemathesis-3.15.4.dist-info/RECORD +0 -99
- schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
SCHEMATHESIS_TEST_CASE_HEADER = "X-Schemathesis-TestCaseId"
|
|
7
|
+
HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER = ":memory:"
|
|
8
|
+
INTERNAL_BUFFER_SIZE = 32 * 1024
|
|
9
|
+
DEFAULT_STATEFUL_STEP_COUNT = 6
|
|
10
|
+
INJECTED_PATH_PARAMETER_KEY = "x-schemathesis-injected"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotSet: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
NOT_SET = NotSet()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SpecificationFeature(str, enum.Enum):
|
|
20
|
+
"""Features that Schemathesis can provide for different specifications."""
|
|
21
|
+
|
|
22
|
+
SCHEMA_ANALYSIS = "schema_analysis"
|
|
23
|
+
STATEFUL_TESTING = "stateful_testing"
|
|
24
|
+
COVERAGE = "coverage_tests"
|
|
25
|
+
EXAMPLES = "example_tests"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Specification:
|
|
30
|
+
kind: SpecificationKind
|
|
31
|
+
version: str
|
|
32
|
+
|
|
33
|
+
__slots__ = ("kind", "version")
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def openapi(cls, version: str) -> Specification:
|
|
37
|
+
return cls(kind=SpecificationKind.OPENAPI, version=version)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def graphql(cls, version: str) -> Specification:
|
|
41
|
+
return cls(kind=SpecificationKind.GRAPHQL, version=version)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
name = {SpecificationKind.GRAPHQL: "GraphQL", SpecificationKind.OPENAPI: "Open API"}[self.kind]
|
|
46
|
+
return f"{name} {self.version}".strip()
|
|
47
|
+
|
|
48
|
+
def supports_feature(self, feature: SpecificationFeature) -> bool:
|
|
49
|
+
"""Check if Schemathesis supports a given feature for this specification."""
|
|
50
|
+
if self.kind == SpecificationKind.OPENAPI:
|
|
51
|
+
return feature in {
|
|
52
|
+
SpecificationFeature.SCHEMA_ANALYSIS,
|
|
53
|
+
SpecificationFeature.STATEFUL_TESTING,
|
|
54
|
+
SpecificationFeature.COVERAGE,
|
|
55
|
+
SpecificationFeature.EXAMPLES,
|
|
56
|
+
}
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SpecificationKind(str, enum.Enum):
|
|
61
|
+
"""Specification of the given schema."""
|
|
62
|
+
|
|
63
|
+
OPENAPI = "openapi"
|
|
64
|
+
GRAPHQL = "graphql"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def string_to_boolean(value: str) -> str | bool:
|
|
68
|
+
if value.lower() in ("y", "yes", "t", "true", "on", "1"):
|
|
69
|
+
return True
|
|
70
|
+
if value.lower() in ("n", "no", "f", "false", "off", "0"):
|
|
71
|
+
return False
|
|
72
|
+
return value
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, TypeVar
|
|
4
|
+
|
|
5
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T", covariant=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResponsesContainer(Protocol[T]):
|
|
11
|
+
def find_by_status_code(self, status_code: int) -> T | None: ... # pragma: no cover
|
|
12
|
+
def add(self, status_code: str, definition: dict[str, Any]) -> T: ... # pragma: no cover
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OperationParameter(Protocol):
|
|
16
|
+
"""API parameter at a specific location (query, header, body, etc.)."""
|
|
17
|
+
|
|
18
|
+
definition: Any
|
|
19
|
+
"""Raw parameter definition from the API spec."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def location(self) -> ParameterLocation:
|
|
23
|
+
"""Location: "query", "header", "body", etc."""
|
|
24
|
+
... # pragma: no cover
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
"""Parameter name."""
|
|
29
|
+
... # pragma: no cover
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_required(self) -> bool:
|
|
33
|
+
"""True if required."""
|
|
34
|
+
... # pragma: no cover
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from jsonschema import RefResolutionError, RefResolver
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
BaseExceptionGroup = BaseExceptionGroup
|
|
11
|
+
except NameError:
|
|
12
|
+
from exceptiongroup import BaseExceptionGroup
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __getattr__(name: str) -> type[RefResolutionError] | type[RefResolver] | type[BaseExceptionGroup]:
|
|
16
|
+
with warnings.catch_warnings():
|
|
17
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
18
|
+
if name == "RefResolutionError":
|
|
19
|
+
# `jsonschema` is pinned, this warning is not useful for the end user
|
|
20
|
+
from jsonschema import RefResolutionError
|
|
21
|
+
|
|
22
|
+
return RefResolutionError
|
|
23
|
+
if name == "RefResolver":
|
|
24
|
+
from jsonschema import RefResolver
|
|
25
|
+
|
|
26
|
+
return RefResolver
|
|
27
|
+
if name == "BaseExceptionGroup":
|
|
28
|
+
return BaseExceptionGroup
|
|
29
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["BaseExceptionGroup", "RefResolutionError"]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from shlex import quote
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from schemathesis.core import SCHEMATHESIS_TEST_CASE_HEADER
|
|
9
|
+
from schemathesis.core.shell import escape_for_shell, has_non_printable
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from requests.models import CaseInsensitiveDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class CurlCommand:
|
|
17
|
+
"""Result of generating a curl command."""
|
|
18
|
+
|
|
19
|
+
command: str
|
|
20
|
+
"""The curl command string."""
|
|
21
|
+
|
|
22
|
+
warnings: list[str]
|
|
23
|
+
"""Warnings about non-printable characters or shell compatibility."""
|
|
24
|
+
|
|
25
|
+
__slots__ = ("command", "warnings")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _escape_and_quote(value: str, warnings: list[str], ctx: str) -> str:
|
|
29
|
+
"""Escape value for shell, adding warnings if needed."""
|
|
30
|
+
if has_non_printable(value):
|
|
31
|
+
escape_result = escape_for_shell(value)
|
|
32
|
+
if escape_result.needs_warning:
|
|
33
|
+
warnings.append(f"{ctx} contains non-printable characters. Actual value: {escape_result.original_bytes!r}")
|
|
34
|
+
return escape_result.escaped_value
|
|
35
|
+
return quote(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def generate(
|
|
39
|
+
*,
|
|
40
|
+
method: str,
|
|
41
|
+
url: str,
|
|
42
|
+
body: str | bytes | None,
|
|
43
|
+
verify: bool,
|
|
44
|
+
headers: dict[str, Any],
|
|
45
|
+
known_generated_headers: dict[str, Any] | None,
|
|
46
|
+
) -> CurlCommand:
|
|
47
|
+
"""Generate a code snippet for making HTTP requests."""
|
|
48
|
+
_filter_headers(headers, known_generated_headers or {})
|
|
49
|
+
warnings: list[str] = []
|
|
50
|
+
command = f"curl -X {method}"
|
|
51
|
+
|
|
52
|
+
# Process headers with shell-aware escaping
|
|
53
|
+
for key, value in headers.items():
|
|
54
|
+
# To send an empty header with cURL we need to use `;`, otherwise empty header is ignored
|
|
55
|
+
if not value:
|
|
56
|
+
header = f"{key};"
|
|
57
|
+
else:
|
|
58
|
+
header = f"{key}: {value}"
|
|
59
|
+
|
|
60
|
+
escaped_header = _escape_and_quote(header, warnings, f"Header '{key}'")
|
|
61
|
+
command += f" -H {escaped_header}"
|
|
62
|
+
|
|
63
|
+
# Process body with shell-aware escaping
|
|
64
|
+
if body:
|
|
65
|
+
if isinstance(body, bytes):
|
|
66
|
+
body = body.decode("utf-8", errors="replace")
|
|
67
|
+
|
|
68
|
+
escaped_body = _escape_and_quote(body, warnings, "Request body")
|
|
69
|
+
command += f" -d {escaped_body}"
|
|
70
|
+
|
|
71
|
+
if not verify:
|
|
72
|
+
command += " --insecure"
|
|
73
|
+
|
|
74
|
+
command += f" {quote(url)}"
|
|
75
|
+
|
|
76
|
+
return CurlCommand(command=command, warnings=warnings)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _filter_headers(headers: dict[str, Any], known_generated_headers: dict[str, Any]) -> None:
|
|
80
|
+
for key in list(headers):
|
|
81
|
+
if key not in known_generated_headers and key in get_excluded_headers():
|
|
82
|
+
del headers[key]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@lru_cache
|
|
86
|
+
def get_excluded_headers() -> CaseInsensitiveDict:
|
|
87
|
+
from requests.structures import CaseInsensitiveDict
|
|
88
|
+
from requests.utils import default_headers
|
|
89
|
+
|
|
90
|
+
# These headers are added automatically by Schemathesis or `requests`.
|
|
91
|
+
# Do not show them in code samples to make them more readable
|
|
92
|
+
|
|
93
|
+
return CaseInsensitiveDict(
|
|
94
|
+
{
|
|
95
|
+
"Content-Length": None,
|
|
96
|
+
"Transfer-Encoding": None,
|
|
97
|
+
SCHEMATHESIS_TEST_CASE_HEADER: None,
|
|
98
|
+
**default_headers(),
|
|
99
|
+
}
|
|
100
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, Iterator, TextIO
|
|
7
|
+
|
|
8
|
+
from schemathesis.core import media_types
|
|
9
|
+
from schemathesis.core.transport import Response
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from schemathesis.generation.case import Case
|
|
15
|
+
from schemathesis.schemas import APIOperation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DeserializationContext:
|
|
20
|
+
"""Context passed to deserializers.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
operation: The API operation being tested.
|
|
24
|
+
case: The generated test case (`None` when validating responses directly).
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
operation: APIOperation
|
|
29
|
+
case: Case | None
|
|
30
|
+
|
|
31
|
+
__slots__ = ("operation", "case")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ResponseDeserializer = Callable[[DeserializationContext, Response], Any]
|
|
35
|
+
|
|
36
|
+
_DESERIALIZERS: Dict[str, ResponseDeserializer] = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _iter_matching_deserializers(media_type: str) -> Iterator[tuple[str, ResponseDeserializer]]:
|
|
40
|
+
main, sub = media_types.parse(media_type)
|
|
41
|
+
checks = [
|
|
42
|
+
media_types.is_json,
|
|
43
|
+
media_types.is_xml,
|
|
44
|
+
media_types.is_plain_text,
|
|
45
|
+
media_types.is_yaml,
|
|
46
|
+
]
|
|
47
|
+
for registered_media_type, deserializer in _DESERIALIZERS.items():
|
|
48
|
+
if any(check(media_type) and check(registered_media_type) for check in checks):
|
|
49
|
+
yield registered_media_type, deserializer
|
|
50
|
+
else:
|
|
51
|
+
target_main, target_sub = media_types.parse(registered_media_type)
|
|
52
|
+
if main in ("*", target_main) and sub in ("*", target_sub):
|
|
53
|
+
yield registered_media_type, deserializer
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def has_deserializer(media_type: str) -> bool:
|
|
57
|
+
"""Check if a deserializer is registered or built-in for the given media type.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
media_type: The media type to check (e.g., "application/msgpack")
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if a deserializer is available (either registered or built-in like JSON/YAML/XML)
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
return (
|
|
67
|
+
media_types.is_json(media_type)
|
|
68
|
+
or media_types.is_yaml(media_type)
|
|
69
|
+
or media_types.is_xml(media_type)
|
|
70
|
+
or media_types.is_plain_text(media_type)
|
|
71
|
+
or any(_iter_matching_deserializers(media_type))
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register_deserializer(func: ResponseDeserializer, *media_types: str) -> ResponseDeserializer:
|
|
76
|
+
for media_type in media_types:
|
|
77
|
+
_DESERIALIZERS[media_type] = func
|
|
78
|
+
return func
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def unregister_deserializer(*media_types: str) -> None:
|
|
82
|
+
for media_type in media_types:
|
|
83
|
+
_DESERIALIZERS.pop(media_type, None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def deserializer(*media_types: str) -> Callable[[ResponseDeserializer], ResponseDeserializer]:
|
|
87
|
+
"""Register a deserializer for custom response media types.
|
|
88
|
+
|
|
89
|
+
Converts API responses with custom content types (MessagePack, domain-specific formats, etc.)
|
|
90
|
+
into Python objects for schema validation. Built-in formats (JSON, YAML) work automatically.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
*media_types: One or more MIME types (e.g., "application/msgpack", "application/vnd.custom+json")
|
|
94
|
+
this deserializer handles. Wildcards are supported (e.g., "application/*").
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A decorator that wraps a function taking `(ctx: DeserializationContext, response: Response)`
|
|
98
|
+
and returning the deserialized Python object for schema validation.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
>>> import schemathesis
|
|
102
|
+
>>> import msgpack
|
|
103
|
+
>>>
|
|
104
|
+
>>> @schemathesis.deserializer("application/msgpack", "application/x-msgpack")
|
|
105
|
+
... def deserialize_msgpack(ctx, response):
|
|
106
|
+
... try:
|
|
107
|
+
... return msgpack.unpackb(response.content, raw=False)
|
|
108
|
+
... except Exception as exc:
|
|
109
|
+
... raise ValueError(f"Invalid MessagePack: {exc}")
|
|
110
|
+
|
|
111
|
+
Notes:
|
|
112
|
+
- Raise appropriate exceptions if deserialization fails; Schemathesis will report them
|
|
113
|
+
- `ctx.operation` provides access to the API operation being tested (always available)
|
|
114
|
+
- `ctx.case` provides the generated test case (None when validating responses directly)
|
|
115
|
+
- Responses with unsupported media types are silently skipped during validation
|
|
116
|
+
- Handle unexpected data defensively, especially during negative testing
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def decorator(func: ResponseDeserializer) -> ResponseDeserializer:
|
|
121
|
+
return register_deserializer(func, *media_types)
|
|
122
|
+
|
|
123
|
+
return decorator
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@lru_cache
|
|
127
|
+
def get_yaml_loader() -> type[yaml.SafeLoader]:
|
|
128
|
+
"""Create a YAML loader, that doesn't parse specific tokens into Python objects."""
|
|
129
|
+
import yaml
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
from yaml import CSafeLoader as SafeLoader
|
|
133
|
+
except ImportError:
|
|
134
|
+
from yaml import SafeLoader # type: ignore[assignment]
|
|
135
|
+
|
|
136
|
+
cls: type[yaml.SafeLoader] = type("YAMLLoader", (SafeLoader,), {})
|
|
137
|
+
cls.yaml_implicit_resolvers = {
|
|
138
|
+
key: [(tag, regexp) for tag, regexp in mapping if tag != "tag:yaml.org,2002:timestamp"]
|
|
139
|
+
for key, mapping in cls.yaml_implicit_resolvers.copy().items()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Fix pyyaml scientific notation parse bug
|
|
143
|
+
# See PR: https://github.com/yaml/pyyaml/pull/174 for upstream fix
|
|
144
|
+
cls.add_implicit_resolver( # type: ignore[no-untyped-call]
|
|
145
|
+
"tag:yaml.org,2002:float",
|
|
146
|
+
re.compile(
|
|
147
|
+
r"""^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)?
|
|
148
|
+
|[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+)
|
|
149
|
+
|\.[0-9_]+(?:[eE][-+]?[0-9]+)?
|
|
150
|
+
|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*
|
|
151
|
+
|[-+]?\.(?:inf|Inf|INF)
|
|
152
|
+
|\.(?:nan|NaN|NAN))$""",
|
|
153
|
+
re.VERBOSE,
|
|
154
|
+
),
|
|
155
|
+
list("-+0123456789."),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def construct_mapping(self: SafeLoader, node: yaml.Node, deep: bool = False) -> dict[str, Any]:
|
|
159
|
+
if isinstance(node, yaml.MappingNode):
|
|
160
|
+
self.flatten_mapping(node)
|
|
161
|
+
mapping = {}
|
|
162
|
+
for key_node, value_node in node.value:
|
|
163
|
+
# If the key has a tag different from `str` - use its string value.
|
|
164
|
+
# With this change all integer keys or YAML 1.1 boolean-ish values like "on" / "off" will not be cast to
|
|
165
|
+
# a different type
|
|
166
|
+
if key_node.tag != "tag:yaml.org,2002:str":
|
|
167
|
+
key = key_node.value
|
|
168
|
+
else:
|
|
169
|
+
key = self.construct_object(key_node, deep) # type: ignore[no-untyped-call]
|
|
170
|
+
mapping[key] = self.construct_object(value_node, deep) # type: ignore[no-untyped-call]
|
|
171
|
+
return mapping
|
|
172
|
+
|
|
173
|
+
cls.construct_mapping = construct_mapping # type: ignore[method-assign,assignment]
|
|
174
|
+
return cls
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def deserialize_yaml(stream: str | bytes | TextIO | BinaryIO) -> Any:
|
|
178
|
+
import yaml
|
|
179
|
+
|
|
180
|
+
return yaml.load(stream, get_yaml_loader())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def deserializers() -> dict[str, ResponseDeserializer]:
|
|
184
|
+
"""Return a snapshot of the registered deserializers."""
|
|
185
|
+
return dict(_DESERIALIZERS)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def deserialize_response(
|
|
189
|
+
response: Response,
|
|
190
|
+
content_type: str,
|
|
191
|
+
*,
|
|
192
|
+
context: DeserializationContext,
|
|
193
|
+
) -> Any:
|
|
194
|
+
for _, deserializer in _iter_matching_deserializers(content_type):
|
|
195
|
+
return deserializer(context, response)
|
|
196
|
+
raise NotImplementedError(
|
|
197
|
+
f"Unsupported Content-Type: {content_type!r}. "
|
|
198
|
+
f"Registered deserializers: {', '.join(sorted(_DESERIALIZERS)) or 'none'}."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@deserializer("application/json")
|
|
203
|
+
def _deserialize_json(_ctx: DeserializationContext, response: Response) -> Any:
|
|
204
|
+
return response.json()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@deserializer(*media_types.YAML_MEDIA_TYPES)
|
|
208
|
+
def _deserialize_yaml(_ctx: DeserializationContext, response: Response) -> Any:
|
|
209
|
+
encoding = response.encoding or "utf-8"
|
|
210
|
+
return deserialize_yaml(response.content.decode(encoding))
|