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.
Files changed (92) hide show
  1. schemathesis/__init__.py +3 -7
  2. schemathesis/checks.py +17 -7
  3. schemathesis/cli/commands/__init__.py +51 -3
  4. schemathesis/cli/commands/data.py +10 -0
  5. schemathesis/cli/commands/run/__init__.py +147 -260
  6. schemathesis/cli/commands/run/context.py +2 -3
  7. schemathesis/cli/commands/run/events.py +4 -0
  8. schemathesis/cli/commands/run/executor.py +60 -73
  9. schemathesis/cli/commands/run/filters.py +15 -165
  10. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  11. schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
  12. schemathesis/cli/commands/run/handlers/output.py +26 -47
  13. schemathesis/cli/commands/run/loaders.py +35 -50
  14. schemathesis/cli/commands/run/validation.py +36 -161
  15. schemathesis/cli/core.py +5 -3
  16. schemathesis/cli/ext/fs.py +7 -5
  17. schemathesis/cli/ext/options.py +0 -21
  18. schemathesis/config/__init__.py +188 -0
  19. schemathesis/config/_auth.py +51 -0
  20. schemathesis/config/_checks.py +268 -0
  21. schemathesis/config/_diff_base.py +99 -0
  22. schemathesis/config/_env.py +21 -0
  23. schemathesis/config/_error.py +156 -0
  24. schemathesis/config/_generation.py +150 -0
  25. schemathesis/config/_health_check.py +24 -0
  26. schemathesis/config/_operations.py +313 -0
  27. schemathesis/config/_output.py +171 -0
  28. schemathesis/config/_parameters.py +19 -0
  29. schemathesis/config/_phases.py +151 -0
  30. schemathesis/config/_projects.py +495 -0
  31. schemathesis/config/_rate_limit.py +17 -0
  32. schemathesis/config/_report.py +116 -0
  33. schemathesis/config/_validator.py +9 -0
  34. schemathesis/config/schema.json +837 -0
  35. schemathesis/core/__init__.py +2 -0
  36. schemathesis/core/compat.py +16 -9
  37. schemathesis/core/errors.py +19 -2
  38. schemathesis/core/failures.py +6 -7
  39. schemathesis/core/hooks.py +20 -0
  40. schemathesis/core/output/__init__.py +14 -37
  41. schemathesis/core/output/sanitization.py +3 -146
  42. schemathesis/core/validation.py +16 -0
  43. schemathesis/engine/__init__.py +2 -4
  44. schemathesis/engine/context.py +41 -43
  45. schemathesis/engine/core.py +7 -5
  46. schemathesis/engine/phases/__init__.py +10 -0
  47. schemathesis/engine/phases/probes.py +8 -8
  48. schemathesis/engine/phases/stateful/_executor.py +68 -43
  49. schemathesis/engine/phases/unit/__init__.py +23 -15
  50. schemathesis/engine/phases/unit/_executor.py +77 -17
  51. schemathesis/engine/phases/unit/_pool.py +1 -1
  52. schemathesis/errors.py +2 -0
  53. schemathesis/filters.py +2 -3
  54. schemathesis/generation/__init__.py +6 -31
  55. schemathesis/generation/case.py +5 -3
  56. schemathesis/generation/coverage.py +153 -123
  57. schemathesis/generation/hypothesis/builder.py +40 -14
  58. schemathesis/generation/meta.py +3 -3
  59. schemathesis/generation/overrides.py +37 -1
  60. schemathesis/generation/stateful/state_machine.py +8 -1
  61. schemathesis/graphql/loaders.py +21 -12
  62. schemathesis/openapi/checks.py +12 -8
  63. schemathesis/openapi/generation/filters.py +10 -8
  64. schemathesis/openapi/loaders.py +22 -13
  65. schemathesis/pytest/lazy.py +2 -5
  66. schemathesis/pytest/plugin.py +11 -2
  67. schemathesis/schemas.py +13 -61
  68. schemathesis/specs/graphql/schemas.py +11 -15
  69. schemathesis/specs/openapi/_hypothesis.py +12 -8
  70. schemathesis/specs/openapi/checks.py +16 -18
  71. schemathesis/specs/openapi/examples.py +4 -3
  72. schemathesis/specs/openapi/formats.py +2 -2
  73. schemathesis/specs/openapi/negative/__init__.py +2 -2
  74. schemathesis/specs/openapi/patterns.py +46 -16
  75. schemathesis/specs/openapi/references.py +2 -3
  76. schemathesis/specs/openapi/schemas.py +11 -20
  77. schemathesis/specs/openapi/stateful/__init__.py +10 -5
  78. schemathesis/transport/prepare.py +7 -6
  79. schemathesis/transport/requests.py +3 -1
  80. schemathesis/transport/wsgi.py +3 -4
  81. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/METADATA +7 -8
  82. schemathesis-4.0.0a11.dist-info/RECORD +166 -0
  83. schemathesis/cli/commands/run/checks.py +0 -79
  84. schemathesis/cli/commands/run/hypothesis.py +0 -78
  85. schemathesis/cli/commands/run/reports.py +0 -72
  86. schemathesis/cli/hooks.py +0 -36
  87. schemathesis/engine/config.py +0 -59
  88. schemathesis/experimental/__init__.py +0 -72
  89. schemathesis-4.0.0a10.dist-info/RECORD +0 -153
  90. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/WHEEL +0 -0
  91. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/entry_points.txt +0 -0
  92. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,9 @@ import enum
4
4
  from dataclasses import dataclass
5
5
 
6
6
  SCHEMATHESIS_TEST_CASE_HEADER = "X-Schemathesis-TestCaseId"
7
+ HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER = ":memory:"
7
8
  INTERNAL_BUFFER_SIZE = 32 * 1024
9
+ DEFAULT_STATEFUL_STEP_COUNT = 6
8
10
 
9
11
 
10
12
  class NotSet: ...
@@ -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
- if name == "RefResolutionError":
16
- # Import it just once to keep just a single warning
17
- from jsonschema import RefResolutionError
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
- return RefResolutionError
20
- if name == "BaseExceptionGroup":
21
- return BaseExceptionGroup
22
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
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"]
@@ -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(cls, error: ValidationError, path: str | None, method: str | None) -> InvalidSchema:
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
 
@@ -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 OutputConfig, prepare_response_payload
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: int,
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 dataclasses import dataclass, replace
5
- from typing import Any
4
+ from typing import TYPE_CHECKING, Any
6
5
 
7
- TRUNCATED = "// Output truncated..."
8
- MAX_PAYLOAD_SIZE = 512
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
- @classmethod
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.truncate:
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) <= config.max_lines:
26
+ if len(lines) <= max_lines:
49
27
  return "\n".join(lines)
50
28
 
51
- truncated_lines = lines[: config.max_lines - 1]
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 | None = None) -> str:
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
- config = config or OutputConfig()
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.core import NOT_SET, NotSet
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
- # Markers indicating potentially sensitive keys
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 | None = None) -> str:
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
@@ -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)
@@ -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, *, config: EngineConfig | None = None) -> Engine:
25
+ def from_schema(schema: BaseSchema) -> Engine:
28
26
  from .core import Engine
29
27
 
30
- return Engine(schema=schema, config=config or EngineConfig())
28
+ return Engine(schema=schema)
@@ -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.checks import CheckContext
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.execution.max_failures)
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
- @cached_property
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.network
85
- session.verify = config.tls_verify
86
- if config.auth is not None:
87
- session.auth = config.auth
88
- if config.headers:
89
- session.headers.update(config.headers)
90
- if config.cert is not None:
91
- session.cert = config.cert
92
- if config.proxy is not None:
93
- session.proxies["all"] = config.proxy
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
- @property
97
- def transport_kwargs(self) -> dict[str, Any]:
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.session,
100
- "headers": self.config.network.headers,
101
- "timeout": self.config.network.timeout,
102
- "verify": self.config.network.tls_verify,
103
- "cert": self.config.network.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
- if self.config.network.proxy is not None:
106
- kwargs["proxies"] = {"all": self.config.network.proxy}
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
- )
@@ -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.network.auth is not None:
24
+ if self.schema.config.auth.is_defined:
27
25
  unregister_auth()
28
26
 
29
- ctx = EngineContext(schema=self.schema, stop_event=threading.Event(), config=self.config)
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
- if phase_name not in self.config.execution.phases:
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 {