schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +26 -68
  3. schemathesis/checks.py +130 -60
  4. schemathesis/cli/__init__.py +5 -2105
  5. schemathesis/cli/commands/__init__.py +37 -0
  6. schemathesis/cli/commands/run/__init__.py +662 -0
  7. schemathesis/cli/commands/run/checks.py +80 -0
  8. schemathesis/cli/commands/run/context.py +117 -0
  9. schemathesis/cli/commands/run/events.py +30 -0
  10. schemathesis/cli/commands/run/executor.py +141 -0
  11. schemathesis/cli/commands/run/filters.py +202 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
  15. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1368 -0
  17. schemathesis/cli/commands/run/hypothesis.py +105 -0
  18. schemathesis/cli/commands/run/loaders.py +129 -0
  19. schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
  20. schemathesis/cli/constants.py +5 -58
  21. schemathesis/cli/core.py +17 -0
  22. schemathesis/cli/ext/fs.py +14 -0
  23. schemathesis/cli/ext/groups.py +55 -0
  24. schemathesis/cli/{options.py → ext/options.py} +37 -16
  25. schemathesis/cli/hooks.py +36 -0
  26. schemathesis/contrib/__init__.py +1 -3
  27. schemathesis/contrib/openapi/__init__.py +1 -3
  28. schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
  29. schemathesis/core/__init__.py +58 -0
  30. schemathesis/core/compat.py +25 -0
  31. schemathesis/core/control.py +2 -0
  32. schemathesis/core/curl.py +58 -0
  33. schemathesis/core/deserialization.py +65 -0
  34. schemathesis/core/errors.py +370 -0
  35. schemathesis/core/failures.py +315 -0
  36. schemathesis/core/fs.py +19 -0
  37. schemathesis/core/loaders.py +104 -0
  38. schemathesis/core/marks.py +66 -0
  39. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  40. schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
  41. schemathesis/core/output/sanitization.py +197 -0
  42. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  43. schemathesis/core/registries.py +31 -0
  44. schemathesis/core/transforms.py +113 -0
  45. schemathesis/core/transport.py +108 -0
  46. schemathesis/core/validation.py +38 -0
  47. schemathesis/core/version.py +7 -0
  48. schemathesis/engine/__init__.py +30 -0
  49. schemathesis/engine/config.py +59 -0
  50. schemathesis/engine/context.py +119 -0
  51. schemathesis/engine/control.py +36 -0
  52. schemathesis/engine/core.py +157 -0
  53. schemathesis/engine/errors.py +394 -0
  54. schemathesis/engine/events.py +243 -0
  55. schemathesis/engine/phases/__init__.py +66 -0
  56. schemathesis/{runner → engine/phases}/probes.py +49 -68
  57. schemathesis/engine/phases/stateful/__init__.py +66 -0
  58. schemathesis/engine/phases/stateful/_executor.py +301 -0
  59. schemathesis/engine/phases/stateful/context.py +85 -0
  60. schemathesis/engine/phases/unit/__init__.py +175 -0
  61. schemathesis/engine/phases/unit/_executor.py +322 -0
  62. schemathesis/engine/phases/unit/_pool.py +74 -0
  63. schemathesis/engine/recorder.py +246 -0
  64. schemathesis/errors.py +31 -0
  65. schemathesis/experimental/__init__.py +9 -40
  66. schemathesis/filters.py +7 -95
  67. schemathesis/generation/__init__.py +3 -3
  68. schemathesis/generation/case.py +190 -0
  69. schemathesis/generation/coverage.py +22 -22
  70. schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
  71. schemathesis/generation/hypothesis/builder.py +585 -0
  72. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  73. schemathesis/generation/hypothesis/given.py +66 -0
  74. schemathesis/generation/hypothesis/reporting.py +14 -0
  75. schemathesis/generation/hypothesis/strategies.py +16 -0
  76. schemathesis/generation/meta.py +115 -0
  77. schemathesis/generation/modes.py +28 -0
  78. schemathesis/generation/overrides.py +96 -0
  79. schemathesis/generation/stateful/__init__.py +20 -0
  80. schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
  81. schemathesis/generation/targets.py +69 -0
  82. schemathesis/graphql/__init__.py +15 -0
  83. schemathesis/graphql/checks.py +109 -0
  84. schemathesis/graphql/loaders.py +131 -0
  85. schemathesis/hooks.py +17 -62
  86. schemathesis/openapi/__init__.py +13 -0
  87. schemathesis/openapi/checks.py +387 -0
  88. schemathesis/openapi/generation/__init__.py +0 -0
  89. schemathesis/openapi/generation/filters.py +63 -0
  90. schemathesis/openapi/loaders.py +178 -0
  91. schemathesis/pytest/__init__.py +5 -0
  92. schemathesis/pytest/control_flow.py +7 -0
  93. schemathesis/pytest/lazy.py +273 -0
  94. schemathesis/pytest/loaders.py +12 -0
  95. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
  96. schemathesis/python/__init__.py +0 -0
  97. schemathesis/python/asgi.py +12 -0
  98. schemathesis/python/wsgi.py +12 -0
  99. schemathesis/schemas.py +456 -228
  100. schemathesis/specs/graphql/__init__.py +0 -1
  101. schemathesis/specs/graphql/_cache.py +1 -2
  102. schemathesis/specs/graphql/scalars.py +5 -3
  103. schemathesis/specs/graphql/schemas.py +122 -123
  104. schemathesis/specs/graphql/validation.py +11 -17
  105. schemathesis/specs/openapi/__init__.py +6 -1
  106. schemathesis/specs/openapi/_cache.py +1 -2
  107. schemathesis/specs/openapi/_hypothesis.py +97 -134
  108. schemathesis/specs/openapi/checks.py +238 -219
  109. schemathesis/specs/openapi/converter.py +4 -4
  110. schemathesis/specs/openapi/definitions.py +1 -1
  111. schemathesis/specs/openapi/examples.py +22 -20
  112. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  113. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  114. schemathesis/specs/openapi/expressions/nodes.py +33 -32
  115. schemathesis/specs/openapi/formats.py +3 -2
  116. schemathesis/specs/openapi/links.py +123 -299
  117. schemathesis/specs/openapi/media_types.py +10 -12
  118. schemathesis/specs/openapi/negative/__init__.py +2 -1
  119. schemathesis/specs/openapi/negative/mutations.py +3 -2
  120. schemathesis/specs/openapi/parameters.py +8 -6
  121. schemathesis/specs/openapi/patterns.py +1 -1
  122. schemathesis/specs/openapi/references.py +11 -51
  123. schemathesis/specs/openapi/schemas.py +177 -191
  124. schemathesis/specs/openapi/security.py +1 -1
  125. schemathesis/specs/openapi/serialization.py +10 -6
  126. schemathesis/specs/openapi/stateful/__init__.py +97 -91
  127. schemathesis/transport/__init__.py +104 -0
  128. schemathesis/transport/asgi.py +26 -0
  129. schemathesis/transport/prepare.py +99 -0
  130. schemathesis/transport/requests.py +221 -0
  131. schemathesis/{_xml.py → transport/serialization.py} +69 -7
  132. schemathesis/transport/wsgi.py +165 -0
  133. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
  134. schemathesis-4.0.0a2.dist-info/RECORD +151 -0
  135. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
  136. schemathesis/_compat.py +0 -74
  137. schemathesis/_dependency_versions.py +0 -19
  138. schemathesis/_hypothesis.py +0 -559
  139. schemathesis/_override.py +0 -50
  140. schemathesis/_rate_limiter.py +0 -7
  141. schemathesis/cli/context.py +0 -75
  142. schemathesis/cli/debug.py +0 -27
  143. schemathesis/cli/handlers.py +0 -19
  144. schemathesis/cli/junitxml.py +0 -124
  145. schemathesis/cli/output/__init__.py +0 -1
  146. schemathesis/cli/output/default.py +0 -936
  147. schemathesis/cli/output/short.py +0 -59
  148. schemathesis/cli/reporting.py +0 -79
  149. schemathesis/cli/sanitization.py +0 -26
  150. schemathesis/code_samples.py +0 -151
  151. schemathesis/constants.py +0 -56
  152. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  153. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  154. schemathesis/contrib/unique_data.py +0 -41
  155. schemathesis/exceptions.py +0 -571
  156. schemathesis/extra/_aiohttp.py +0 -28
  157. schemathesis/extra/_flask.py +0 -13
  158. schemathesis/extra/_server.py +0 -18
  159. schemathesis/failures.py +0 -277
  160. schemathesis/fixups/__init__.py +0 -37
  161. schemathesis/fixups/fast_api.py +0 -41
  162. schemathesis/fixups/utf8_bom.py +0 -28
  163. schemathesis/generation/_methods.py +0 -44
  164. schemathesis/graphql.py +0 -3
  165. schemathesis/internal/__init__.py +0 -7
  166. schemathesis/internal/checks.py +0 -84
  167. schemathesis/internal/copy.py +0 -32
  168. schemathesis/internal/datetime.py +0 -5
  169. schemathesis/internal/deprecation.py +0 -38
  170. schemathesis/internal/diff.py +0 -15
  171. schemathesis/internal/extensions.py +0 -27
  172. schemathesis/internal/jsonschema.py +0 -36
  173. schemathesis/internal/transformation.py +0 -26
  174. schemathesis/internal/validation.py +0 -34
  175. schemathesis/lazy.py +0 -474
  176. schemathesis/loaders.py +0 -122
  177. schemathesis/models.py +0 -1341
  178. schemathesis/parameters.py +0 -90
  179. schemathesis/runner/__init__.py +0 -605
  180. schemathesis/runner/events.py +0 -389
  181. schemathesis/runner/impl/__init__.py +0 -3
  182. schemathesis/runner/impl/context.py +0 -104
  183. schemathesis/runner/impl/core.py +0 -1246
  184. schemathesis/runner/impl/solo.py +0 -80
  185. schemathesis/runner/impl/threadpool.py +0 -391
  186. schemathesis/runner/serialization.py +0 -544
  187. schemathesis/sanitization.py +0 -252
  188. schemathesis/serializers.py +0 -328
  189. schemathesis/service/__init__.py +0 -18
  190. schemathesis/service/auth.py +0 -11
  191. schemathesis/service/ci.py +0 -202
  192. schemathesis/service/client.py +0 -133
  193. schemathesis/service/constants.py +0 -38
  194. schemathesis/service/events.py +0 -61
  195. schemathesis/service/extensions.py +0 -224
  196. schemathesis/service/hosts.py +0 -111
  197. schemathesis/service/metadata.py +0 -71
  198. schemathesis/service/models.py +0 -258
  199. schemathesis/service/report.py +0 -255
  200. schemathesis/service/serialization.py +0 -173
  201. schemathesis/service/usage.py +0 -66
  202. schemathesis/specs/graphql/loaders.py +0 -364
  203. schemathesis/specs/openapi/expressions/context.py +0 -16
  204. schemathesis/specs/openapi/loaders.py +0 -708
  205. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  206. schemathesis/specs/openapi/stateful/types.py +0 -14
  207. schemathesis/specs/openapi/validation.py +0 -26
  208. schemathesis/stateful/__init__.py +0 -147
  209. schemathesis/stateful/config.py +0 -97
  210. schemathesis/stateful/context.py +0 -135
  211. schemathesis/stateful/events.py +0 -274
  212. schemathesis/stateful/runner.py +0 -309
  213. schemathesis/stateful/sink.py +0 -68
  214. schemathesis/stateful/statistic.py +0 -22
  215. schemathesis/stateful/validation.py +0 -100
  216. schemathesis/targets.py +0 -77
  217. schemathesis/transports/__init__.py +0 -359
  218. schemathesis/transports/asgi.py +0 -7
  219. schemathesis/transports/auth.py +0 -38
  220. schemathesis/transports/headers.py +0 -36
  221. schemathesis/transports/responses.py +0 -57
  222. schemathesis/types.py +0 -44
  223. schemathesis/utils.py +0 -164
  224. schemathesis-3.39.7.dist-info/RECORD +0 -160
  225. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  226. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  227. /schemathesis/{internal → core}/result.py +0 -0
  228. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  229. {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ from typing import TYPE_CHECKING, Any, Callable, NoReturn
5
+
6
+ from schemathesis.core.errors import LoaderError, LoaderErrorKind, get_request_error_extras, get_request_error_message
7
+ from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, USER_AGENT
8
+
9
+ if TYPE_CHECKING:
10
+ import requests
11
+
12
+
13
+ def prepare_request_kwargs(kwargs: dict[str, Any]) -> None:
14
+ """Prepare common request kwargs."""
15
+ headers = kwargs.setdefault("headers", {})
16
+ if "user-agent" not in {header.lower() for header in headers}:
17
+ kwargs["headers"]["User-Agent"] = USER_AGENT
18
+
19
+
20
+ def handle_request_error(exc: requests.RequestException) -> NoReturn:
21
+ """Handle request-level errors."""
22
+ import requests
23
+
24
+ url = exc.request.url if exc.request is not None else None
25
+ if isinstance(exc, requests.exceptions.SSLError):
26
+ kind = LoaderErrorKind.CONNECTION_SSL
27
+ elif isinstance(exc, requests.exceptions.ConnectionError):
28
+ kind = LoaderErrorKind.CONNECTION_OTHER
29
+ else:
30
+ kind = LoaderErrorKind.NETWORK_OTHER
31
+ raise LoaderError(
32
+ message=get_request_error_message(exc),
33
+ kind=kind,
34
+ url=url,
35
+ extras=get_request_error_extras(exc),
36
+ ) from exc
37
+
38
+
39
+ def raise_for_status(response: requests.Response) -> requests.Response:
40
+ """Handle response status codes."""
41
+ status_code = response.status_code
42
+ if status_code < 400:
43
+ return response
44
+
45
+ reason = http.client.responses.get(status_code, "Unknown")
46
+ if status_code >= 500:
47
+ message = f"Failed to load schema due to server error (HTTP {status_code} {reason})"
48
+ kind = LoaderErrorKind.HTTP_SERVER_ERROR
49
+ else:
50
+ message = f"Failed to load schema due to client error (HTTP {status_code} {reason})"
51
+ kind = (
52
+ LoaderErrorKind.HTTP_FORBIDDEN
53
+ if status_code == 403
54
+ else LoaderErrorKind.HTTP_NOT_FOUND
55
+ if status_code == 404
56
+ else LoaderErrorKind.HTTP_CLIENT_ERROR
57
+ )
58
+ raise LoaderError(message=message, kind=kind, url=response.request.url, extras=[])
59
+
60
+
61
+ def make_request(func: Callable[..., requests.Response], url: str, **kwargs: Any) -> requests.Response:
62
+ """Make HTTP request with error handling."""
63
+ import requests
64
+
65
+ try:
66
+ response = func(url, **kwargs)
67
+ return raise_for_status(response)
68
+ except requests.RequestException as exc:
69
+ handle_request_error(exc)
70
+
71
+
72
+ WAIT_FOR_SCHEMA_INTERVAL = 0.05
73
+
74
+
75
+ def load_from_url(
76
+ func: Callable[..., requests.Response],
77
+ *,
78
+ url: str,
79
+ wait_for_schema: float | None = None,
80
+ **kwargs: Any,
81
+ ) -> requests.Response:
82
+ """Load schema from URL with retries."""
83
+ import backoff
84
+ import requests
85
+
86
+ kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT)
87
+ prepare_request_kwargs(kwargs)
88
+
89
+ if wait_for_schema is not None:
90
+ func = backoff.on_exception(
91
+ backoff.constant,
92
+ requests.exceptions.ConnectionError,
93
+ max_time=wait_for_schema,
94
+ interval=WAIT_FOR_SCHEMA_INTERVAL,
95
+ )(func)
96
+
97
+ return make_request(func, url, **kwargs)
98
+
99
+
100
+ def require_relative_url(url: str) -> None:
101
+ """Raise an error if the URL is not relative."""
102
+ # Deliberately simplistic approach
103
+ if "://" in url or url.startswith("//"):
104
+ raise ValueError("Schema path should be relative for WSGI/ASGI loaders")
@@ -0,0 +1,66 @@
1
+ """A lightweight mechanism to attach Schemathesis-specific metadata to test functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, Generic, TypeVar
7
+
8
+ from schemathesis.core import NOT_SET, NotSet
9
+
10
+ METADATA_ATTR = "_schemathesis_metadata"
11
+
12
+
13
+ @dataclass
14
+ class SchemathesisMetadata:
15
+ """Container for all Schemathesis-specific data attached to test functions."""
16
+
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class Mark(Generic[T]):
22
+ """Access to specific attributes in SchemathesisMetadata."""
23
+
24
+ def __init__(
25
+ self, *, attr_name: str, default: T | Callable[[], T] | None = None, check: Callable[[T], bool] | None = None
26
+ ) -> None:
27
+ self.attr_name = attr_name
28
+ self._default = default
29
+ self._check = check
30
+
31
+ def _get_default(self) -> T | None:
32
+ if callable(self._default):
33
+ return self._default()
34
+ return self._default
35
+
36
+ def _check_value(self, value: T) -> bool:
37
+ if self._check is not None:
38
+ return self._check(value)
39
+ return True
40
+
41
+ def get(self, func: Callable) -> T | None:
42
+ """Get marker value if it's set."""
43
+ metadata = getattr(func, METADATA_ATTR, None)
44
+ if metadata is None:
45
+ return self._get_default()
46
+ value = getattr(metadata, self.attr_name, NOT_SET)
47
+ if value is NOT_SET:
48
+ return self._get_default()
49
+ assert not isinstance(value, NotSet)
50
+ if self._check_value(value):
51
+ return value
52
+ return self._get_default()
53
+
54
+ def set(self, func: Callable, value: T) -> None:
55
+ """Set marker value, creating metadata if needed."""
56
+ if not hasattr(func, METADATA_ATTR):
57
+ setattr(func, METADATA_ATTR, SchemathesisMetadata())
58
+ metadata = getattr(func, METADATA_ATTR)
59
+ setattr(metadata, self.attr_name, value)
60
+
61
+ def is_set(self, func: Callable) -> bool:
62
+ """Check if function has metadata with this marker set."""
63
+ metadata = getattr(func, METADATA_ATTR, None)
64
+ if metadata is None:
65
+ return False
66
+ return hasattr(metadata, self.attr_name)
@@ -1,6 +1,8 @@
1
1
  from functools import lru_cache
2
2
  from typing import Generator, Tuple
3
3
 
4
+ from schemathesis.core.errors import MalformedMediaType
5
+
4
6
 
5
7
  def _parseparam(s: str) -> Generator[str, None, None]:
6
8
  while s[:1] == ";":
@@ -15,7 +17,7 @@ def _parseparam(s: str) -> Generator[str, None, None]:
15
17
  s = s[end:]
16
18
 
17
19
 
18
- def parse_header(line: str) -> Tuple[str, dict]:
20
+ def _parse_header(line: str) -> Tuple[str, dict]:
19
21
  parts = _parseparam(";" + line)
20
22
  key = parts.__next__()
21
23
  pdict = {}
@@ -32,36 +34,36 @@ def parse_header(line: str) -> Tuple[str, dict]:
32
34
 
33
35
 
34
36
  @lru_cache
35
- def parse_content_type(content_type: str) -> Tuple[str, str]:
37
+ def parse(media_type: str) -> Tuple[str, str]:
36
38
  """Parse Content Type and return main type and subtype."""
37
39
  try:
38
- content_type, _ = parse_header(content_type)
39
- main_type, sub_type = content_type.split("/", 1)
40
+ media_type, _ = _parse_header(media_type)
41
+ main_type, sub_type = media_type.split("/", 1)
40
42
  except ValueError as exc:
41
- raise ValueError(f"Malformed media type: `{content_type}`") from exc
43
+ raise MalformedMediaType(f"Malformed media type: `{media_type}`") from exc
42
44
  return main_type.lower(), sub_type.lower()
43
45
 
44
46
 
45
- def is_json_media_type(value: str) -> bool:
47
+ def is_json(value: str) -> bool:
46
48
  """Detect whether the content type is JSON-compatible.
47
49
 
48
50
  For example - ``application/problem+json`` matches.
49
51
  """
50
- main, sub = parse_content_type(value)
52
+ main, sub = parse(value)
51
53
  return main == "application" and (sub == "json" or sub.endswith("+json"))
52
54
 
53
55
 
54
- def is_yaml_media_type(value: str) -> bool:
56
+ def is_yaml(value: str) -> bool:
55
57
  """Detect whether the content type is YAML-compatible."""
56
58
  return value in ("text/yaml", "text/x-yaml", "application/x-yaml", "text/vnd.yaml")
57
59
 
58
60
 
59
- def is_plain_text_media_type(value: str) -> bool:
61
+ def is_plain_text(value: str) -> bool:
60
62
  """Detect variations of the ``text/plain`` media type."""
61
- return parse_content_type(value) == ("text", "plain")
63
+ return parse(value) == ("text", "plain")
62
64
 
63
65
 
64
- def is_xml_media_type(value: str) -> bool:
66
+ def is_xml(value: str) -> bool:
65
67
  """Detect variations of the ``application/xml`` media type."""
66
- _, sub = parse_content_type(value)
68
+ _, sub = parse(value)
67
69
  return sub == "xml" or sub.endswith("+xml")
@@ -14,6 +14,7 @@ MAX_WIDTH = 80
14
14
  class OutputConfig:
15
15
  """Options for configuring various aspects of Schemathesis output."""
16
16
 
17
+ sanitize: bool = True
17
18
  truncate: bool = True
18
19
  max_payload_size: int = MAX_PAYLOAD_SIZE
19
20
  max_lines: int = MAX_LINES
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import MutableMapping, MutableSequence
4
+ from dataclasses import dataclass, replace
5
+ from typing import Any
6
+ from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
7
+
8
+ from schemathesis.core import NOT_SET, NotSet
9
+
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
+
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:
152
+ """Sanitize sensitive values within a given item.
153
+
154
+ This function is recursive and will sanitize sensitive data within nested
155
+ dictionaries and lists as well.
156
+ """
157
+ config = config or _DEFAULT_SANITIZATION_CONFIG
158
+ if isinstance(item, MutableMapping):
159
+ for key in list(item.keys()):
160
+ lower_key = key.lower()
161
+ if lower_key in config.keys_to_sanitize or any(marker in lower_key for marker in config.sensitive_markers):
162
+ if isinstance(item[key], list):
163
+ item[key] = [config.replacement]
164
+ else:
165
+ item[key] = config.replacement
166
+ for value in item.values():
167
+ if isinstance(value, (MutableMapping, MutableSequence)):
168
+ sanitize_value(value, config=config)
169
+ elif isinstance(item, MutableSequence):
170
+ for value in item:
171
+ if isinstance(value, (MutableMapping, MutableSequence)):
172
+ sanitize_value(value, config=config)
173
+
174
+
175
+ def sanitize_url(url: str, *, config: SanitizationConfig | None = None) -> str:
176
+ """Sanitize sensitive parts of a given URL.
177
+
178
+ This function will sanitize the authority and query parameters in the URL.
179
+ """
180
+ config = config or _DEFAULT_SANITIZATION_CONFIG
181
+ parsed = urlsplit(url)
182
+
183
+ # Sanitize authority
184
+ netloc_parts = parsed.netloc.split("@")
185
+ if len(netloc_parts) > 1:
186
+ netloc = f"{config.replacement}@{netloc_parts[-1]}"
187
+ else:
188
+ netloc = parsed.netloc
189
+
190
+ # Sanitize query parameters
191
+ query = parse_qs(parsed.query, keep_blank_values=True)
192
+ sanitize_value(query, config=config)
193
+ sanitized_query = urlencode(query, doseq=True)
194
+
195
+ # Reconstruct the URL
196
+ sanitized_url_parts = parsed._replace(netloc=netloc, query=sanitized_query)
197
+ return urlunsplit(sanitized_url_parts)
@@ -1,14 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
3
+ from contextlib import nullcontext
4
+ from typing import TYPE_CHECKING, ContextManager
5
+ from urllib.parse import urlparse
4
6
 
5
- from ._dependency_versions import IS_PYRATE_LIMITER_ABOVE_3
6
- from .exceptions import UsageError
7
+ from schemathesis.core.errors import InvalidRateLimit
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  from pyrate_limiter import Duration, Limiter
10
11
 
11
12
 
13
+ def ratelimit(rate_limiter: Limiter | None, base_url: str | None) -> ContextManager:
14
+ """Limit the rate of sending generated requests."""
15
+ label = urlparse(base_url).netloc
16
+ if rate_limiter is not None:
17
+ rate_limiter.try_acquire(label)
18
+ return nullcontext()
19
+
20
+
12
21
  def parse_units(rate: str) -> tuple[int, int]:
13
22
  from pyrate_limiter import Duration
14
23
 
@@ -21,17 +30,10 @@ def parse_units(rate: str) -> tuple[int, int]:
21
30
  "d": Duration.DAY,
22
31
  }.get(interval_text)
23
32
  if interval is None:
24
- raise invalid_rate(rate)
33
+ raise InvalidRateLimit(rate)
25
34
  return int(limit), interval
26
35
  except ValueError as exc:
27
- raise invalid_rate(rate) from exc
28
-
29
-
30
- def invalid_rate(value: str) -> UsageError:
31
- return UsageError(
32
- f"Invalid rate limit value: `{value}`. Should be in form `limit/interval`. "
33
- "Example: `10/m` for 10 requests per minute."
34
- )
36
+ raise InvalidRateLimit(rate) from exc
35
37
 
36
38
 
37
39
  def _get_max_delay(value: int, unit: Duration) -> int:
@@ -51,11 +53,8 @@ def _get_max_delay(value: int, unit: Duration) -> int:
51
53
 
52
54
 
53
55
  def build_limiter(rate: str) -> Limiter:
54
- from ._rate_limiter import Limiter, Rate
56
+ from pyrate_limiter import Limiter, Rate
55
57
 
56
58
  limit, interval = parse_units(rate)
57
59
  rate = Rate(limit, interval)
58
- kwargs = {}
59
- if IS_PYRATE_LIMITER_ABOVE_3:
60
- kwargs["max_delay"] = _get_max_delay(limit, interval)
61
- return Limiter(rate, **kwargs)
60
+ return Limiter(rate, max_delay=_get_max_delay(limit, interval))
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, Generic, Sequence, TypeVar, Union
4
+
5
+ T = TypeVar("T", bound=Union[Callable, type])
6
+
7
+
8
+ class Registry(Generic[T]):
9
+ """Container for Schemathesis extensions."""
10
+
11
+ __slots__ = ("_items",)
12
+
13
+ def __init__(self) -> None:
14
+ self._items: dict[str, T] = {}
15
+
16
+ def register(self, item: T) -> T:
17
+ self._items[item.__name__] = item
18
+ return item
19
+
20
+ def unregister(self, name: str) -> None:
21
+ del self._items[name]
22
+
23
+ def get_all_names(self) -> list[str]:
24
+ return list(self._items)
25
+
26
+ def get_all(self) -> list[T]:
27
+ return list(self._items.values())
28
+
29
+ def get_by_names(self, names: Sequence[str]) -> list[T]:
30
+ """Get items by their names."""
31
+ return [self._items[name] for name in names]
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, List, Mapping, Union, overload
4
+
5
+
6
+ def deepclone(value: Any) -> Any:
7
+ """A specialized version of `deepcopy` that copies only `dict` and `list` and does unrolling.
8
+
9
+ It is on average 3x faster than `deepcopy` and given the amount of calls, it is an important optimization.
10
+ """
11
+ if isinstance(value, dict):
12
+ return {
13
+ k1: (
14
+ {k2: deepclone(v2) for k2, v2 in v1.items()}
15
+ if isinstance(v1, dict)
16
+ else [deepclone(v2) for v2 in v1]
17
+ if isinstance(v1, list)
18
+ else v1
19
+ )
20
+ for k1, v1 in value.items()
21
+ }
22
+ if isinstance(value, list):
23
+ return [
24
+ {k2: deepclone(v2) for k2, v2 in v1.items()}
25
+ if isinstance(v1, dict)
26
+ else [deepclone(v2) for v2 in v1]
27
+ if isinstance(v1, list)
28
+ else v1
29
+ for v1 in value
30
+ ]
31
+ return value
32
+
33
+
34
+ def diff(left: Mapping[str, Any], right: Mapping[str, Any]) -> dict[str, Any]:
35
+ """Calculate the difference between two dictionaries."""
36
+ diff = {}
37
+ for key, value in right.items():
38
+ if key not in left or left[key] != value:
39
+ diff[key] = value
40
+ return diff
41
+
42
+
43
+ def merge_at(data: dict[str, Any], data_key: str, new: dict[str, Any]) -> None:
44
+ original = data[data_key] or {}
45
+ for key, value in new.items():
46
+ original[key] = value
47
+ data[data_key] = original
48
+
49
+
50
+ JsonValue = Union[Dict[str, Any], List, str, float, int]
51
+
52
+
53
+ @overload
54
+ def transform(schema: dict[str, Any], callback: Callable, *args: Any, **kwargs: Any) -> dict[str, Any]: ...
55
+
56
+
57
+ @overload
58
+ def transform(schema: list, callback: Callable, *args: Any, **kwargs: Any) -> list: ...
59
+
60
+
61
+ @overload
62
+ def transform(schema: str, callback: Callable, *args: Any, **kwargs: Any) -> str: ...
63
+
64
+
65
+ @overload
66
+ def transform(schema: float, callback: Callable, *args: Any, **kwargs: Any) -> float: ...
67
+
68
+
69
+ def transform(schema: JsonValue, callback: Callable[..., dict[str, Any]], *args: Any, **kwargs: Any) -> JsonValue:
70
+ """Apply callback recursively to the given schema."""
71
+ if isinstance(schema, dict):
72
+ schema = callback(schema, *args, **kwargs)
73
+ for key, sub_item in schema.items():
74
+ schema[key] = transform(sub_item, callback, *args, **kwargs)
75
+ elif isinstance(schema, list):
76
+ schema = [transform(sub_item, callback, *args, **kwargs) for sub_item in schema]
77
+ return schema
78
+
79
+
80
+ class Unresolvable: ...
81
+
82
+
83
+ UNRESOLVABLE = Unresolvable()
84
+
85
+
86
+ def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | float | None | Unresolvable:
87
+ """Implementation is adapted from Rust's `serde-json` crate.
88
+
89
+ Ref: https://github.com/serde-rs/json/blob/master/src/value/mod.rs#L751
90
+ """
91
+ if not pointer:
92
+ return document
93
+ if not pointer.startswith("/"):
94
+ return UNRESOLVABLE
95
+
96
+ def replace(value: str) -> str:
97
+ return value.replace("~1", "/").replace("~0", "~")
98
+
99
+ tokens = map(replace, pointer.split("/")[1:])
100
+ target = document
101
+ for token in tokens:
102
+ if isinstance(target, dict):
103
+ target = target.get(token, UNRESOLVABLE)
104
+ if target is UNRESOLVABLE:
105
+ return UNRESOLVABLE
106
+ elif isinstance(target, list):
107
+ try:
108
+ target = target[int(token)]
109
+ except IndexError:
110
+ return UNRESOLVABLE
111
+ else:
112
+ return UNRESOLVABLE
113
+ return target