schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a11__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 +3 -7
- schemathesis/checks.py +17 -7
- schemathesis/cli/commands/__init__.py +51 -3
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +147 -260
- schemathesis/cli/commands/run/context.py +2 -3
- schemathesis/cli/commands/run/events.py +4 -0
- schemathesis/cli/commands/run/executor.py +60 -73
- schemathesis/cli/commands/run/filters.py +15 -165
- schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
- schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
- schemathesis/cli/commands/run/handlers/output.py +26 -47
- schemathesis/cli/commands/run/loaders.py +35 -50
- schemathesis/cli/commands/run/validation.py +36 -161
- schemathesis/cli/core.py +5 -3
- schemathesis/cli/ext/fs.py +7 -5
- schemathesis/cli/ext/options.py +0 -21
- schemathesis/config/__init__.py +188 -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 +150 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +313 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +151 -0
- schemathesis/config/_projects.py +495 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +116 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/schema.json +837 -0
- schemathesis/core/__init__.py +2 -0
- schemathesis/core/compat.py +16 -9
- schemathesis/core/errors.py +19 -2
- schemathesis/core/failures.py +6 -7
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/output/__init__.py +14 -37
- schemathesis/core/output/sanitization.py +3 -146
- schemathesis/core/validation.py +16 -0
- schemathesis/engine/__init__.py +2 -4
- schemathesis/engine/context.py +41 -43
- schemathesis/engine/core.py +7 -5
- schemathesis/engine/phases/__init__.py +10 -0
- schemathesis/engine/phases/probes.py +8 -8
- schemathesis/engine/phases/stateful/_executor.py +68 -43
- schemathesis/engine/phases/unit/__init__.py +23 -15
- schemathesis/engine/phases/unit/_executor.py +77 -17
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/errors.py +2 -0
- schemathesis/filters.py +2 -3
- schemathesis/generation/__init__.py +6 -31
- schemathesis/generation/case.py +5 -3
- schemathesis/generation/coverage.py +153 -123
- schemathesis/generation/hypothesis/builder.py +40 -14
- schemathesis/generation/meta.py +3 -3
- schemathesis/generation/overrides.py +37 -1
- schemathesis/generation/stateful/state_machine.py +8 -1
- schemathesis/graphql/loaders.py +21 -12
- schemathesis/openapi/checks.py +12 -8
- schemathesis/openapi/generation/filters.py +10 -8
- schemathesis/openapi/loaders.py +22 -13
- schemathesis/pytest/lazy.py +2 -5
- schemathesis/pytest/plugin.py +11 -2
- schemathesis/schemas.py +13 -61
- schemathesis/specs/graphql/schemas.py +11 -15
- schemathesis/specs/openapi/_hypothesis.py +12 -8
- schemathesis/specs/openapi/checks.py +16 -18
- schemathesis/specs/openapi/examples.py +4 -3
- schemathesis/specs/openapi/formats.py +2 -2
- schemathesis/specs/openapi/negative/__init__.py +2 -2
- schemathesis/specs/openapi/patterns.py +46 -16
- schemathesis/specs/openapi/references.py +2 -3
- schemathesis/specs/openapi/schemas.py +11 -20
- schemathesis/specs/openapi/stateful/__init__.py +10 -5
- schemathesis/transport/prepare.py +7 -6
- schemathesis/transport/requests.py +3 -1
- schemathesis/transport/wsgi.py +3 -4
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/METADATA +7 -8
- schemathesis-4.0.0a11.dist-info/RECORD +166 -0
- schemathesis/cli/commands/run/checks.py +0 -79
- schemathesis/cli/commands/run/hypothesis.py +0 -78
- schemathesis/cli/commands/run/reports.py +0 -72
- schemathesis/cli/hooks.py +0 -36
- schemathesis/engine/config.py +0 -59
- schemathesis/experimental/__init__.py +0 -72
- schemathesis-4.0.0a10.dist-info/RECORD +0 -153
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/licenses/LICENSE +0 -0
schemathesis/core/__init__.py
CHANGED
schemathesis/core/compat.py
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import warnings
|
3
4
|
from typing import TYPE_CHECKING
|
4
5
|
|
5
6
|
if TYPE_CHECKING:
|
6
|
-
from jsonschema import RefResolutionError
|
7
|
+
from jsonschema import RefResolutionError, RefResolver
|
7
8
|
|
8
9
|
try:
|
9
10
|
BaseExceptionGroup = BaseExceptionGroup # type: ignore
|
@@ -11,15 +12,21 @@ except NameError:
|
|
11
12
|
from exceptiongroup import BaseExceptionGroup # type: ignore
|
12
13
|
|
13
14
|
|
14
|
-
def __getattr__(name: str) -> type[RefResolutionError] | type[BaseExceptionGroup]:
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
18
21
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
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}")
|
23
30
|
|
24
31
|
|
25
32
|
__all__ = ["BaseExceptionGroup", "RefResolutionError"]
|
schemathesis/core/errors.py
CHANGED
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
|
15
15
|
from jsonschema import ValidationError
|
16
16
|
from requests import RequestException
|
17
17
|
|
18
|
+
from schemathesis.config import OutputConfig
|
18
19
|
from schemathesis.core.compat import RefResolutionError
|
19
20
|
|
20
21
|
|
@@ -44,7 +45,9 @@ class InvalidSchema(SchemathesisError):
|
|
44
45
|
self.method = method
|
45
46
|
|
46
47
|
@classmethod
|
47
|
-
def from_jsonschema_error(
|
48
|
+
def from_jsonschema_error(
|
49
|
+
cls, error: ValidationError, path: str | None, method: str | None, config: OutputConfig
|
50
|
+
) -> InvalidSchema:
|
48
51
|
if error.absolute_path:
|
49
52
|
part = error.absolute_path[-1]
|
50
53
|
if isinstance(part, int) and len(error.absolute_path) > 1:
|
@@ -56,7 +59,7 @@ class InvalidSchema(SchemathesisError):
|
|
56
59
|
message = "Invalid schema definition"
|
57
60
|
error_path = " -> ".join(str(entry) for entry in error.path) or "[root]"
|
58
61
|
message += f"\n\nLocation:\n {error_path}"
|
59
|
-
instance = truncate_json(error.instance)
|
62
|
+
instance = truncate_json(error.instance, config=config)
|
60
63
|
message += f"\n\nProblematic definition:\n{instance}"
|
61
64
|
message += "\n\nError details:\n "
|
62
65
|
# This default message contains the instance which we already printed
|
@@ -94,6 +97,20 @@ class InvalidSchema(SchemathesisError):
|
|
94
97
|
return actual_test
|
95
98
|
|
96
99
|
|
100
|
+
class HookError(SchemathesisError):
|
101
|
+
"""Happens during hooks loading."""
|
102
|
+
|
103
|
+
module_path: str
|
104
|
+
|
105
|
+
__slots__ = ("module_path",)
|
106
|
+
|
107
|
+
def __init__(self, module_path: str) -> None:
|
108
|
+
self.module_path = module_path
|
109
|
+
|
110
|
+
def __str__(self) -> str:
|
111
|
+
return f"Failed to load Schemathesis extensions from `{self.module_path}`"
|
112
|
+
|
113
|
+
|
97
114
|
class InvalidRegexType(InvalidSchema):
|
98
115
|
"""Raised when an invalid type is used where a regex pattern is expected."""
|
99
116
|
|
schemathesis/core/failures.py
CHANGED
@@ -9,8 +9,9 @@ from enum import Enum, auto
|
|
9
9
|
from json import JSONDecodeError
|
10
10
|
from typing import Any, Callable
|
11
11
|
|
12
|
+
from schemathesis.config import OutputConfig
|
12
13
|
from schemathesis.core.compat import BaseExceptionGroup
|
13
|
-
from schemathesis.core.output import
|
14
|
+
from schemathesis.core.output import prepare_response_payload
|
14
15
|
from schemathesis.core.transport import Response
|
15
16
|
|
16
17
|
|
@@ -123,11 +124,6 @@ class CustomFailure(Failure):
|
|
123
124
|
return self.origin
|
124
125
|
|
125
126
|
|
126
|
-
@dataclass
|
127
|
-
class MaxResponseTimeConfig:
|
128
|
-
limit: float = 10.0
|
129
|
-
|
130
|
-
|
131
127
|
class ResponseTimeExceeded(Failure):
|
132
128
|
"""Response took longer than expected."""
|
133
129
|
|
@@ -138,7 +134,7 @@ class ResponseTimeExceeded(Failure):
|
|
138
134
|
*,
|
139
135
|
operation: str,
|
140
136
|
elapsed: float,
|
141
|
-
deadline:
|
137
|
+
deadline: float,
|
142
138
|
message: str,
|
143
139
|
title: str = "Response time limit exceeded",
|
144
140
|
case_id: str | None = None,
|
@@ -245,6 +241,9 @@ class FailureGroup(BaseExceptionGroup):
|
|
245
241
|
|
246
242
|
exceptions: Sequence[Failure]
|
247
243
|
|
244
|
+
def __init__(self, exceptions: Sequence[Failure], message: str = "", /) -> None:
|
245
|
+
super().__init__(message, exceptions)
|
246
|
+
|
248
247
|
def __new__(cls, failures: Sequence[Failure], message: str | None = None) -> FailureGroup:
|
249
248
|
if message is None:
|
250
249
|
message = failure_report_title(failures)
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
|
4
|
+
from schemathesis.core.errors import HookError
|
5
|
+
|
6
|
+
HOOKS_MODULE_ENV_VAR = "SCHEMATHESIS_HOOKS"
|
7
|
+
|
8
|
+
|
9
|
+
def load_from_env() -> None:
|
10
|
+
hooks = os.getenv(HOOKS_MODULE_ENV_VAR)
|
11
|
+
if hooks:
|
12
|
+
load_from_path(hooks)
|
13
|
+
|
14
|
+
|
15
|
+
def load_from_path(module_path: str) -> None:
|
16
|
+
try:
|
17
|
+
sys.path.append(os.getcwd()) # fix ModuleNotFoundError module in cwd
|
18
|
+
__import__(module_path)
|
19
|
+
except Exception as exc:
|
20
|
+
raise HookError(module_path) from exc
|
@@ -1,54 +1,32 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import json
|
4
|
-
from
|
5
|
-
from typing import Any
|
4
|
+
from typing import TYPE_CHECKING, Any
|
6
5
|
|
7
|
-
|
8
|
-
|
9
|
-
MAX_LINES = 10
|
10
|
-
MAX_WIDTH = 80
|
11
|
-
|
12
|
-
|
13
|
-
@dataclass
|
14
|
-
class OutputConfig:
|
15
|
-
"""Options for configuring various aspects of Schemathesis output."""
|
16
|
-
|
17
|
-
sanitize: bool = True
|
18
|
-
truncate: bool = True
|
19
|
-
max_payload_size: int = MAX_PAYLOAD_SIZE
|
20
|
-
max_lines: int = MAX_LINES
|
21
|
-
max_width: int = MAX_WIDTH
|
6
|
+
if TYPE_CHECKING:
|
7
|
+
from schemathesis.config import OutputConfig
|
22
8
|
|
23
|
-
|
24
|
-
def from_parent(cls, parent: OutputConfig | None = None, **changes: Any) -> OutputConfig:
|
25
|
-
parent = parent or OutputConfig()
|
26
|
-
return parent.replace(**changes)
|
27
|
-
|
28
|
-
def replace(self, **changes: Any) -> OutputConfig:
|
29
|
-
"""Create a new instance with updated values."""
|
30
|
-
return replace(self, **changes)
|
9
|
+
TRUNCATED = "// Output truncated..."
|
31
10
|
|
32
11
|
|
33
|
-
def truncate_json(data: Any, *, config: OutputConfig | None = None) -> str:
|
34
|
-
config = config or OutputConfig()
|
12
|
+
def truncate_json(data: Any, *, config: OutputConfig, max_lines: int | None = None) -> str:
|
35
13
|
# Convert JSON to string with indentation
|
36
14
|
indent = 4
|
37
15
|
serialized = json.dumps(data, indent=indent)
|
38
|
-
if not config.
|
16
|
+
if not config.truncation.enabled:
|
39
17
|
return serialized
|
40
18
|
|
19
|
+
max_lines = max_lines if max_lines is not None else config.truncation.max_lines
|
41
20
|
# Split string by lines
|
42
|
-
|
43
21
|
lines = [
|
44
|
-
line[: config.max_width - 3] + "..." if len(line) > config.max_width else line
|
22
|
+
line[: config.truncation.max_width - 3] + "..." if len(line) > config.truncation.max_width else line
|
45
23
|
for line in serialized.split("\n")
|
46
24
|
]
|
47
25
|
|
48
|
-
if len(lines) <=
|
26
|
+
if len(lines) <= max_lines:
|
49
27
|
return "\n".join(lines)
|
50
28
|
|
51
|
-
truncated_lines = lines[:
|
29
|
+
truncated_lines = lines[: max_lines - 1]
|
52
30
|
indentation = " " * indent
|
53
31
|
truncated_lines.append(f"{indentation}{TRUNCATED}")
|
54
32
|
truncated_lines.append(lines[-1])
|
@@ -56,14 +34,13 @@ def truncate_json(data: Any, *, config: OutputConfig | None = None) -> str:
|
|
56
34
|
return "\n".join(truncated_lines)
|
57
35
|
|
58
36
|
|
59
|
-
def prepare_response_payload(payload: str, *, config: OutputConfig
|
37
|
+
def prepare_response_payload(payload: str, *, config: OutputConfig) -> str:
|
60
38
|
if payload.endswith("\r\n"):
|
61
39
|
payload = payload[:-2]
|
62
40
|
elif payload.endswith("\n"):
|
63
41
|
payload = payload[:-1]
|
64
|
-
|
65
|
-
if not config.truncate:
|
42
|
+
if not config.truncation.enabled:
|
66
43
|
return payload
|
67
|
-
if len(payload) > config.max_payload_size:
|
68
|
-
payload = payload[: config.max_payload_size] + f" {TRUNCATED}"
|
44
|
+
if len(payload) > config.truncation.max_payload_size:
|
45
|
+
payload = payload[: config.truncation.max_payload_size] + f" {TRUNCATED}"
|
69
46
|
return payload
|
@@ -1,160 +1,18 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
from collections.abc import MutableMapping, MutableSequence
|
4
|
-
from dataclasses import dataclass, replace
|
5
4
|
from typing import Any
|
6
5
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
7
6
|
|
8
|
-
from schemathesis.
|
7
|
+
from schemathesis.config import SanitizationConfig
|
9
8
|
|
10
|
-
# Exact keys to sanitize
|
11
|
-
DEFAULT_KEYS_TO_SANITIZE = frozenset(
|
12
|
-
(
|
13
|
-
"phpsessid",
|
14
|
-
"xsrf-token",
|
15
|
-
"_csrf",
|
16
|
-
"_csrf_token",
|
17
|
-
"_session",
|
18
|
-
"_xsrf",
|
19
|
-
"aiohttp_session",
|
20
|
-
"api_key",
|
21
|
-
"api-key",
|
22
|
-
"apikey",
|
23
|
-
"auth",
|
24
|
-
"authorization",
|
25
|
-
"connect.sid",
|
26
|
-
"cookie",
|
27
|
-
"credentials",
|
28
|
-
"csrf",
|
29
|
-
"csrf_token",
|
30
|
-
"csrf-token",
|
31
|
-
"csrftoken",
|
32
|
-
"ip_address",
|
33
|
-
"mysql_pwd",
|
34
|
-
"passwd",
|
35
|
-
"password",
|
36
|
-
"private_key",
|
37
|
-
"private-key",
|
38
|
-
"privatekey",
|
39
|
-
"remote_addr",
|
40
|
-
"remote-addr",
|
41
|
-
"secret",
|
42
|
-
"session",
|
43
|
-
"sessionid",
|
44
|
-
"set_cookie",
|
45
|
-
"set-cookie",
|
46
|
-
"token",
|
47
|
-
"x_api_key",
|
48
|
-
"x-api-key",
|
49
|
-
"x_csrftoken",
|
50
|
-
"x-csrftoken",
|
51
|
-
"x_forwarded_for",
|
52
|
-
"x-forwarded-for",
|
53
|
-
"x_real_ip",
|
54
|
-
"x-real-ip",
|
55
|
-
)
|
56
|
-
)
|
57
9
|
|
58
|
-
|
59
|
-
DEFAULT_SENSITIVE_MARKERS = frozenset(
|
60
|
-
(
|
61
|
-
"token",
|
62
|
-
"key",
|
63
|
-
"secret",
|
64
|
-
"password",
|
65
|
-
"auth",
|
66
|
-
"session",
|
67
|
-
"passwd",
|
68
|
-
"credential",
|
69
|
-
)
|
70
|
-
)
|
71
|
-
|
72
|
-
DEFAULT_REPLACEMENT = "[Filtered]"
|
73
|
-
|
74
|
-
|
75
|
-
@dataclass
|
76
|
-
class SanitizationConfig:
|
77
|
-
"""Configuration class for sanitizing sensitive data."""
|
78
|
-
|
79
|
-
keys_to_sanitize: frozenset[str] = DEFAULT_KEYS_TO_SANITIZE
|
80
|
-
sensitive_markers: frozenset[str] = DEFAULT_SENSITIVE_MARKERS
|
81
|
-
replacement: str = DEFAULT_REPLACEMENT
|
82
|
-
|
83
|
-
@classmethod
|
84
|
-
def from_config(
|
85
|
-
cls,
|
86
|
-
base_config: SanitizationConfig,
|
87
|
-
*,
|
88
|
-
replacement: str | NotSet = NOT_SET,
|
89
|
-
keys_to_sanitize: list[str] | NotSet = NOT_SET,
|
90
|
-
sensitive_markers: list[str] | NotSet = NOT_SET,
|
91
|
-
) -> SanitizationConfig:
|
92
|
-
"""Create a new config by replacing specified values."""
|
93
|
-
kwargs: dict[str, Any] = {}
|
94
|
-
if not isinstance(replacement, NotSet):
|
95
|
-
kwargs["replacement"] = replacement
|
96
|
-
if not isinstance(keys_to_sanitize, NotSet):
|
97
|
-
kwargs["keys_to_sanitize"] = frozenset(key.lower() for key in keys_to_sanitize)
|
98
|
-
if not isinstance(sensitive_markers, NotSet):
|
99
|
-
kwargs["sensitive_markers"] = frozenset(marker.lower() for marker in sensitive_markers)
|
100
|
-
return replace(base_config, **kwargs)
|
101
|
-
|
102
|
-
def extend(
|
103
|
-
self,
|
104
|
-
*,
|
105
|
-
keys_to_sanitize: list[str] | NotSet = NOT_SET,
|
106
|
-
sensitive_markers: list[str] | NotSet = NOT_SET,
|
107
|
-
) -> SanitizationConfig:
|
108
|
-
"""Create a new config by extending current sets."""
|
109
|
-
config = self
|
110
|
-
if not isinstance(keys_to_sanitize, NotSet):
|
111
|
-
new_keys = config.keys_to_sanitize.union(key.lower() for key in keys_to_sanitize)
|
112
|
-
config = replace(config, keys_to_sanitize=new_keys)
|
113
|
-
|
114
|
-
if not isinstance(sensitive_markers, NotSet):
|
115
|
-
new_markers = config.sensitive_markers.union(marker.lower() for marker in sensitive_markers)
|
116
|
-
config = replace(config, sensitive_markers=new_markers)
|
117
|
-
|
118
|
-
return config
|
119
|
-
|
120
|
-
|
121
|
-
_DEFAULT_SANITIZATION_CONFIG = SanitizationConfig()
|
122
|
-
|
123
|
-
|
124
|
-
def configure(
|
125
|
-
replacement: str | NotSet = NOT_SET,
|
126
|
-
keys_to_sanitize: list[str] | NotSet = NOT_SET,
|
127
|
-
sensitive_markers: list[str] | NotSet = NOT_SET,
|
128
|
-
) -> None:
|
129
|
-
"""Replace current sanitization configuration."""
|
130
|
-
global _DEFAULT_SANITIZATION_CONFIG
|
131
|
-
_DEFAULT_SANITIZATION_CONFIG = SanitizationConfig.from_config(
|
132
|
-
_DEFAULT_SANITIZATION_CONFIG,
|
133
|
-
replacement=replacement,
|
134
|
-
keys_to_sanitize=keys_to_sanitize,
|
135
|
-
sensitive_markers=sensitive_markers,
|
136
|
-
)
|
137
|
-
|
138
|
-
|
139
|
-
def extend(
|
140
|
-
keys_to_sanitize: list[str] | NotSet = NOT_SET,
|
141
|
-
sensitive_markers: list[str] | NotSet = NOT_SET,
|
142
|
-
) -> None:
|
143
|
-
"""Extend current sanitization configuration."""
|
144
|
-
global _DEFAULT_SANITIZATION_CONFIG
|
145
|
-
_DEFAULT_SANITIZATION_CONFIG = _DEFAULT_SANITIZATION_CONFIG.extend(
|
146
|
-
keys_to_sanitize=keys_to_sanitize,
|
147
|
-
sensitive_markers=sensitive_markers,
|
148
|
-
)
|
149
|
-
|
150
|
-
|
151
|
-
def sanitize_value(item: Any, *, config: SanitizationConfig | None = None) -> None:
|
10
|
+
def sanitize_value(item: Any, *, config: SanitizationConfig) -> None:
|
152
11
|
"""Sanitize sensitive values within a given item.
|
153
12
|
|
154
13
|
This function is recursive and will sanitize sensitive data within nested
|
155
14
|
dictionaries and lists as well.
|
156
15
|
"""
|
157
|
-
config = config or _DEFAULT_SANITIZATION_CONFIG
|
158
16
|
if isinstance(item, MutableMapping):
|
159
17
|
for key in list(item.keys()):
|
160
18
|
lower_key = key.lower()
|
@@ -172,12 +30,11 @@ def sanitize_value(item: Any, *, config: SanitizationConfig | None = None) -> No
|
|
172
30
|
sanitize_value(value, config=config)
|
173
31
|
|
174
32
|
|
175
|
-
def sanitize_url(url: str, *, config: SanitizationConfig
|
33
|
+
def sanitize_url(url: str, *, config: SanitizationConfig) -> str:
|
176
34
|
"""Sanitize sensitive parts of a given URL.
|
177
35
|
|
178
36
|
This function will sanitize the authority and query parameters in the URL.
|
179
37
|
"""
|
180
|
-
config = config or _DEFAULT_SANITIZATION_CONFIG
|
181
38
|
parsed = urlsplit(url)
|
182
39
|
|
183
40
|
# Sanitize authority
|
schemathesis/core/validation.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
import re
|
2
|
+
from urllib.parse import urlparse
|
2
3
|
|
3
4
|
# Adapted from http.client._is_illegal_header_value
|
4
5
|
INVALID_HEADER_RE = re.compile(r"\n(?![ \t])|\r(?![ \t\n])")
|
@@ -36,3 +37,18 @@ def contains_unicode_surrogate_pair(item: object) -> bool:
|
|
36
37
|
if isinstance(item, list):
|
37
38
|
return any(isinstance(item_, str) and bool(_contains_surrogate_pair(item_)) for item_ in item)
|
38
39
|
return isinstance(item, str) and bool(_contains_surrogate_pair(item))
|
40
|
+
|
41
|
+
|
42
|
+
INVALID_BASE_URL_MESSAGE = (
|
43
|
+
"The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
|
44
|
+
"Make sure it is a properly formatted URL."
|
45
|
+
)
|
46
|
+
|
47
|
+
|
48
|
+
def validate_base_url(value: str) -> None:
|
49
|
+
try:
|
50
|
+
netloc = urlparse(value).netloc
|
51
|
+
except ValueError as exc:
|
52
|
+
raise ValueError(INVALID_BASE_URL_MESSAGE) from exc
|
53
|
+
if value and not netloc:
|
54
|
+
raise ValueError(INVALID_BASE_URL_MESSAGE)
|
schemathesis/engine/__init__.py
CHANGED
@@ -3,8 +3,6 @@ from __future__ import annotations
|
|
3
3
|
from enum import Enum
|
4
4
|
from typing import TYPE_CHECKING
|
5
5
|
|
6
|
-
from schemathesis.engine.config import EngineConfig
|
7
|
-
|
8
6
|
if TYPE_CHECKING:
|
9
7
|
from schemathesis.engine.core import Engine
|
10
8
|
from schemathesis.schemas import BaseSchema
|
@@ -24,7 +22,7 @@ class Status(str, Enum):
|
|
24
22
|
_STATUS_ORDER = {Status.SUCCESS: 0, Status.FAILURE: 1, Status.ERROR: 2, Status.INTERRUPTED: 3, Status.SKIP: 4}
|
25
23
|
|
26
24
|
|
27
|
-
def from_schema(schema: BaseSchema
|
25
|
+
def from_schema(schema: BaseSchema) -> Engine:
|
28
26
|
from .core import Engine
|
29
27
|
|
30
|
-
return Engine(schema=schema
|
28
|
+
return Engine(schema=schema)
|
schemathesis/engine/context.py
CHANGED
@@ -2,14 +2,12 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import time
|
4
4
|
from dataclasses import dataclass
|
5
|
-
from functools import cached_property
|
6
5
|
from typing import TYPE_CHECKING, Any
|
7
6
|
|
8
|
-
from schemathesis.
|
7
|
+
from schemathesis.config import ProjectConfig
|
9
8
|
from schemathesis.core import NOT_SET, NotSet
|
10
|
-
from schemathesis.engine.recorder import ScenarioRecorder
|
11
9
|
from schemathesis.generation.case import Case
|
12
|
-
from schemathesis.schemas import BaseSchema
|
10
|
+
from schemathesis.schemas import APIOperation, BaseSchema
|
13
11
|
|
14
12
|
from .control import ExecutionControl
|
15
13
|
|
@@ -18,8 +16,6 @@ if TYPE_CHECKING:
|
|
18
16
|
|
19
17
|
import requests
|
20
18
|
|
21
|
-
from schemathesis.engine.config import EngineConfig
|
22
|
-
|
23
19
|
|
24
20
|
@dataclass
|
25
21
|
class EngineContext:
|
@@ -28,26 +24,30 @@ class EngineContext:
|
|
28
24
|
schema: BaseSchema
|
29
25
|
control: ExecutionControl
|
30
26
|
outcome_cache: dict[int, BaseException | None]
|
31
|
-
config: EngineConfig
|
32
27
|
start_time: float
|
33
28
|
|
29
|
+
__slots__ = ("schema", "control", "outcome_cache", "start_time", "_session", "_transport_kwargs_cache")
|
30
|
+
|
34
31
|
def __init__(
|
35
32
|
self,
|
36
33
|
*,
|
37
34
|
schema: BaseSchema,
|
38
35
|
stop_event: threading.Event,
|
39
|
-
config: EngineConfig,
|
40
36
|
session: requests.Session | None = None,
|
41
37
|
) -> None:
|
42
38
|
self.schema = schema
|
43
|
-
self.control = ExecutionControl(stop_event=stop_event, max_failures=config.
|
39
|
+
self.control = ExecutionControl(stop_event=stop_event, max_failures=schema.config.max_failures)
|
44
40
|
self.outcome_cache = {}
|
45
|
-
self.config = config
|
46
41
|
self.start_time = time.monotonic()
|
47
42
|
self._session = session
|
43
|
+
self._transport_kwargs_cache: dict[str | None, dict[str, Any]] = {}
|
48
44
|
|
49
45
|
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
50
46
|
|
47
|
+
@property
|
48
|
+
def config(self) -> ProjectConfig:
|
49
|
+
return self.schema.config
|
50
|
+
|
51
51
|
@property
|
52
52
|
def running_time(self) -> float:
|
53
53
|
return time.monotonic() - self.start_time
|
@@ -74,46 +74,44 @@ class EngineContext:
|
|
74
74
|
def get_cached_outcome(self, case: Case) -> BaseException | None | NotSet:
|
75
75
|
return self.outcome_cache.get(hash(case), NOT_SET)
|
76
76
|
|
77
|
-
|
78
|
-
def session(self) -> requests.Session:
|
77
|
+
def get_session(self, *, operation: APIOperation | None = None) -> requests.Session:
|
79
78
|
if self._session is not None:
|
80
79
|
return self._session
|
81
80
|
import requests
|
82
81
|
|
83
82
|
session = requests.Session()
|
84
|
-
config = self.config
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
if
|
89
|
-
session.
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
83
|
+
config = self.config
|
84
|
+
|
85
|
+
session.verify = config.tls_verify_for(operation=operation)
|
86
|
+
auth = config.auth_for(operation=operation)
|
87
|
+
if auth is not None:
|
88
|
+
session.auth = auth
|
89
|
+
headers = config.headers_for(operation=operation)
|
90
|
+
if headers:
|
91
|
+
session.headers.update(headers)
|
92
|
+
request_cert = config.request_cert_for(operation=operation)
|
93
|
+
if request_cert is not None:
|
94
|
+
session.cert = request_cert
|
95
|
+
proxy = config.proxy_for(operation=operation)
|
96
|
+
if proxy is not None:
|
97
|
+
session.proxies["all"] = proxy
|
94
98
|
return session
|
95
99
|
|
96
|
-
|
97
|
-
|
100
|
+
def get_transport_kwargs(self, operation: APIOperation | None = None) -> dict[str, Any]:
|
101
|
+
key = operation.label if operation is not None else None
|
102
|
+
cached = self._transport_kwargs_cache.get(key)
|
103
|
+
if cached is not None:
|
104
|
+
return cached.copy()
|
105
|
+
config = self.config
|
98
106
|
kwargs: dict[str, Any] = {
|
99
|
-
"session": self.
|
100
|
-
"headers":
|
101
|
-
"timeout":
|
102
|
-
"verify":
|
103
|
-
"cert":
|
107
|
+
"session": self.get_session(operation=operation),
|
108
|
+
"headers": config.headers_for(operation=operation),
|
109
|
+
"timeout": config.request_timeout_for(operation=operation),
|
110
|
+
"verify": config.tls_verify_for(operation=operation),
|
111
|
+
"cert": config.request_cert_for(operation=operation),
|
104
112
|
}
|
105
|
-
|
106
|
-
|
113
|
+
proxy = config.proxy_for(operation=operation)
|
114
|
+
if proxy is not None:
|
115
|
+
kwargs["proxies"] = {"all": proxy}
|
116
|
+
self._transport_kwargs_cache[key] = kwargs
|
107
117
|
return kwargs
|
108
|
-
|
109
|
-
def get_check_context(self, recorder: ScenarioRecorder) -> CheckContext:
|
110
|
-
from requests.models import CaseInsensitiveDict
|
111
|
-
|
112
|
-
return CheckContext(
|
113
|
-
override=self.config.override,
|
114
|
-
auth=self.config.network.auth,
|
115
|
-
headers=CaseInsensitiveDict(self.config.network.headers) if self.config.network.headers else None,
|
116
|
-
config=self.config.checks_config,
|
117
|
-
transport_kwargs=self.transport_kwargs,
|
118
|
-
recorder=recorder,
|
119
|
-
)
|
schemathesis/engine/core.py
CHANGED
@@ -9,7 +9,6 @@ from schemathesis.core import SpecificationFeature
|
|
9
9
|
from schemathesis.engine import Status, events, phases
|
10
10
|
from schemathesis.schemas import BaseSchema
|
11
11
|
|
12
|
-
from .config import EngineConfig
|
13
12
|
from .context import EngineContext
|
14
13
|
from .events import EventGenerator
|
15
14
|
from .phases import Phase, PhaseName, PhaseSkipReason
|
@@ -18,15 +17,14 @@ from .phases import Phase, PhaseName, PhaseSkipReason
|
|
18
17
|
@dataclass
|
19
18
|
class Engine:
|
20
19
|
schema: BaseSchema
|
21
|
-
config: EngineConfig
|
22
20
|
|
23
21
|
def execute(self) -> EventStream:
|
24
22
|
"""Execute all test phases."""
|
25
23
|
# Unregister auth if explicitly provided
|
26
|
-
if self.config.
|
24
|
+
if self.schema.config.auth.is_defined:
|
27
25
|
unregister_auth()
|
28
26
|
|
29
|
-
ctx = EngineContext(schema=self.schema, stop_event=threading.Event()
|
27
|
+
ctx = EngineContext(schema=self.schema, stop_event=threading.Event())
|
30
28
|
plan = self._create_execution_plan()
|
31
29
|
return EventStream(plan.execute(ctx), ctx.control.stop_event)
|
32
30
|
|
@@ -70,7 +68,11 @@ class Engine:
|
|
70
68
|
skip_reason=PhaseSkipReason.NOT_SUPPORTED,
|
71
69
|
)
|
72
70
|
|
73
|
-
|
71
|
+
phase = phase_name.value.lower()
|
72
|
+
if (
|
73
|
+
phase in ("examples", "coverage", "fuzzing", "stateful")
|
74
|
+
and not self.schema.config.phases.get_by_name(name=phase).enabled
|
75
|
+
):
|
74
76
|
return Phase(
|
75
77
|
name=phase_name,
|
76
78
|
is_supported=True,
|
@@ -23,6 +23,16 @@ class PhaseName(str, enum.Enum):
|
|
23
23
|
def defaults(cls) -> list[PhaseName]:
|
24
24
|
return [PhaseName.EXAMPLES, PhaseName.COVERAGE, PhaseName.FUZZING, PhaseName.STATEFUL_TESTING]
|
25
25
|
|
26
|
+
@property
|
27
|
+
def name(self) -> str:
|
28
|
+
return {
|
29
|
+
PhaseName.PROBING: "probing",
|
30
|
+
PhaseName.EXAMPLES: "examples",
|
31
|
+
PhaseName.COVERAGE: "coverage",
|
32
|
+
PhaseName.FUZZING: "fuzzing",
|
33
|
+
PhaseName.STATEFUL_TESTING: "stateful",
|
34
|
+
}[self]
|
35
|
+
|
26
36
|
@classmethod
|
27
37
|
def from_str(cls, value: str) -> PhaseName:
|
28
38
|
return {
|