schemathesis 3.39.15__py3-none-any.whl → 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. schemathesis/__init__.py +41 -79
  2. schemathesis/auths.py +111 -122
  3. schemathesis/checks.py +169 -60
  4. schemathesis/cli/__init__.py +15 -2117
  5. schemathesis/cli/commands/__init__.py +85 -0
  6. schemathesis/cli/commands/data.py +10 -0
  7. schemathesis/cli/commands/run/__init__.py +590 -0
  8. schemathesis/cli/commands/run/context.py +204 -0
  9. schemathesis/cli/commands/run/events.py +60 -0
  10. schemathesis/cli/commands/run/executor.py +157 -0
  11. schemathesis/cli/commands/run/filters.py +53 -0
  12. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  13. schemathesis/cli/commands/run/handlers/base.py +18 -0
  14. schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
  15. schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
  16. schemathesis/cli/commands/run/handlers/output.py +1628 -0
  17. schemathesis/cli/commands/run/loaders.py +114 -0
  18. schemathesis/cli/commands/run/validation.py +246 -0
  19. schemathesis/cli/constants.py +5 -58
  20. schemathesis/cli/core.py +19 -0
  21. schemathesis/cli/ext/fs.py +16 -0
  22. schemathesis/cli/ext/groups.py +84 -0
  23. schemathesis/cli/{options.py → ext/options.py} +36 -34
  24. schemathesis/config/__init__.py +189 -0
  25. schemathesis/config/_auth.py +51 -0
  26. schemathesis/config/_checks.py +268 -0
  27. schemathesis/config/_diff_base.py +99 -0
  28. schemathesis/config/_env.py +21 -0
  29. schemathesis/config/_error.py +156 -0
  30. schemathesis/config/_generation.py +149 -0
  31. schemathesis/config/_health_check.py +24 -0
  32. schemathesis/config/_operations.py +327 -0
  33. schemathesis/config/_output.py +171 -0
  34. schemathesis/config/_parameters.py +19 -0
  35. schemathesis/config/_phases.py +187 -0
  36. schemathesis/config/_projects.py +527 -0
  37. schemathesis/config/_rate_limit.py +17 -0
  38. schemathesis/config/_report.py +120 -0
  39. schemathesis/config/_validator.py +9 -0
  40. schemathesis/config/_warnings.py +25 -0
  41. schemathesis/config/schema.json +885 -0
  42. schemathesis/core/__init__.py +67 -0
  43. schemathesis/core/compat.py +32 -0
  44. schemathesis/core/control.py +2 -0
  45. schemathesis/core/curl.py +58 -0
  46. schemathesis/core/deserialization.py +65 -0
  47. schemathesis/core/errors.py +459 -0
  48. schemathesis/core/failures.py +315 -0
  49. schemathesis/core/fs.py +19 -0
  50. schemathesis/core/hooks.py +20 -0
  51. schemathesis/core/loaders.py +104 -0
  52. schemathesis/core/marks.py +66 -0
  53. schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
  54. schemathesis/core/output/__init__.py +46 -0
  55. schemathesis/core/output/sanitization.py +54 -0
  56. schemathesis/{throttling.py → core/rate_limit.py} +16 -17
  57. schemathesis/core/registries.py +31 -0
  58. schemathesis/core/transforms.py +113 -0
  59. schemathesis/core/transport.py +223 -0
  60. schemathesis/core/validation.py +54 -0
  61. schemathesis/core/version.py +7 -0
  62. schemathesis/engine/__init__.py +28 -0
  63. schemathesis/engine/context.py +118 -0
  64. schemathesis/engine/control.py +36 -0
  65. schemathesis/engine/core.py +169 -0
  66. schemathesis/engine/errors.py +464 -0
  67. schemathesis/engine/events.py +258 -0
  68. schemathesis/engine/phases/__init__.py +88 -0
  69. schemathesis/{runner → engine/phases}/probes.py +52 -68
  70. schemathesis/engine/phases/stateful/__init__.py +68 -0
  71. schemathesis/engine/phases/stateful/_executor.py +356 -0
  72. schemathesis/engine/phases/stateful/context.py +85 -0
  73. schemathesis/engine/phases/unit/__init__.py +212 -0
  74. schemathesis/engine/phases/unit/_executor.py +416 -0
  75. schemathesis/engine/phases/unit/_pool.py +82 -0
  76. schemathesis/engine/recorder.py +247 -0
  77. schemathesis/errors.py +43 -0
  78. schemathesis/filters.py +17 -98
  79. schemathesis/generation/__init__.py +5 -33
  80. schemathesis/generation/case.py +317 -0
  81. schemathesis/generation/coverage.py +282 -175
  82. schemathesis/generation/hypothesis/__init__.py +36 -0
  83. schemathesis/generation/hypothesis/builder.py +800 -0
  84. schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
  85. schemathesis/generation/hypothesis/given.py +66 -0
  86. schemathesis/generation/hypothesis/reporting.py +14 -0
  87. schemathesis/generation/hypothesis/strategies.py +16 -0
  88. schemathesis/generation/meta.py +115 -0
  89. schemathesis/generation/metrics.py +93 -0
  90. schemathesis/generation/modes.py +20 -0
  91. schemathesis/generation/overrides.py +116 -0
  92. schemathesis/generation/stateful/__init__.py +37 -0
  93. schemathesis/generation/stateful/state_machine.py +278 -0
  94. schemathesis/graphql/__init__.py +15 -0
  95. schemathesis/graphql/checks.py +109 -0
  96. schemathesis/graphql/loaders.py +284 -0
  97. schemathesis/hooks.py +80 -101
  98. schemathesis/openapi/__init__.py +13 -0
  99. schemathesis/openapi/checks.py +455 -0
  100. schemathesis/openapi/generation/__init__.py +0 -0
  101. schemathesis/openapi/generation/filters.py +72 -0
  102. schemathesis/openapi/loaders.py +313 -0
  103. schemathesis/pytest/__init__.py +5 -0
  104. schemathesis/pytest/control_flow.py +7 -0
  105. schemathesis/pytest/lazy.py +281 -0
  106. schemathesis/pytest/loaders.py +36 -0
  107. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
  108. schemathesis/python/__init__.py +0 -0
  109. schemathesis/python/asgi.py +12 -0
  110. schemathesis/python/wsgi.py +12 -0
  111. schemathesis/schemas.py +537 -273
  112. schemathesis/specs/graphql/__init__.py +0 -1
  113. schemathesis/specs/graphql/_cache.py +1 -2
  114. schemathesis/specs/graphql/scalars.py +42 -6
  115. schemathesis/specs/graphql/schemas.py +141 -137
  116. schemathesis/specs/graphql/validation.py +11 -17
  117. schemathesis/specs/openapi/__init__.py +6 -1
  118. schemathesis/specs/openapi/_cache.py +1 -2
  119. schemathesis/specs/openapi/_hypothesis.py +142 -156
  120. schemathesis/specs/openapi/checks.py +368 -257
  121. schemathesis/specs/openapi/converter.py +4 -4
  122. schemathesis/specs/openapi/definitions.py +1 -1
  123. schemathesis/specs/openapi/examples.py +23 -21
  124. schemathesis/specs/openapi/expressions/__init__.py +31 -19
  125. schemathesis/specs/openapi/expressions/extractors.py +1 -4
  126. schemathesis/specs/openapi/expressions/lexer.py +1 -1
  127. schemathesis/specs/openapi/expressions/nodes.py +36 -41
  128. schemathesis/specs/openapi/expressions/parser.py +1 -1
  129. schemathesis/specs/openapi/formats.py +35 -7
  130. schemathesis/specs/openapi/media_types.py +53 -12
  131. schemathesis/specs/openapi/negative/__init__.py +7 -4
  132. schemathesis/specs/openapi/negative/mutations.py +6 -5
  133. schemathesis/specs/openapi/parameters.py +7 -10
  134. schemathesis/specs/openapi/patterns.py +94 -31
  135. schemathesis/specs/openapi/references.py +12 -53
  136. schemathesis/specs/openapi/schemas.py +238 -308
  137. schemathesis/specs/openapi/security.py +1 -1
  138. schemathesis/specs/openapi/serialization.py +12 -6
  139. schemathesis/specs/openapi/stateful/__init__.py +268 -133
  140. schemathesis/specs/openapi/stateful/control.py +87 -0
  141. schemathesis/specs/openapi/stateful/links.py +209 -0
  142. schemathesis/transport/__init__.py +142 -0
  143. schemathesis/transport/asgi.py +26 -0
  144. schemathesis/transport/prepare.py +124 -0
  145. schemathesis/transport/requests.py +244 -0
  146. schemathesis/{_xml.py → transport/serialization.py} +69 -11
  147. schemathesis/transport/wsgi.py +171 -0
  148. schemathesis-4.0.0.dist-info/METADATA +204 -0
  149. schemathesis-4.0.0.dist-info/RECORD +164 -0
  150. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
  151. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
  152. schemathesis/_compat.py +0 -74
  153. schemathesis/_dependency_versions.py +0 -19
  154. schemathesis/_hypothesis.py +0 -712
  155. schemathesis/_override.py +0 -50
  156. schemathesis/_patches.py +0 -21
  157. schemathesis/_rate_limiter.py +0 -7
  158. schemathesis/cli/callbacks.py +0 -466
  159. schemathesis/cli/cassettes.py +0 -561
  160. schemathesis/cli/context.py +0 -75
  161. schemathesis/cli/debug.py +0 -27
  162. schemathesis/cli/handlers.py +0 -19
  163. schemathesis/cli/junitxml.py +0 -124
  164. schemathesis/cli/output/__init__.py +0 -1
  165. schemathesis/cli/output/default.py +0 -920
  166. schemathesis/cli/output/short.py +0 -59
  167. schemathesis/cli/reporting.py +0 -79
  168. schemathesis/cli/sanitization.py +0 -26
  169. schemathesis/code_samples.py +0 -151
  170. schemathesis/constants.py +0 -54
  171. schemathesis/contrib/__init__.py +0 -11
  172. schemathesis/contrib/openapi/__init__.py +0 -11
  173. schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
  174. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  175. schemathesis/contrib/openapi/formats/uuid.py +0 -16
  176. schemathesis/contrib/unique_data.py +0 -41
  177. schemathesis/exceptions.py +0 -571
  178. schemathesis/experimental/__init__.py +0 -109
  179. schemathesis/extra/_aiohttp.py +0 -28
  180. schemathesis/extra/_flask.py +0 -13
  181. schemathesis/extra/_server.py +0 -18
  182. schemathesis/failures.py +0 -284
  183. schemathesis/fixups/__init__.py +0 -37
  184. schemathesis/fixups/fast_api.py +0 -41
  185. schemathesis/fixups/utf8_bom.py +0 -28
  186. schemathesis/generation/_methods.py +0 -44
  187. schemathesis/graphql.py +0 -3
  188. schemathesis/internal/__init__.py +0 -7
  189. schemathesis/internal/checks.py +0 -86
  190. schemathesis/internal/copy.py +0 -32
  191. schemathesis/internal/datetime.py +0 -5
  192. schemathesis/internal/deprecation.py +0 -37
  193. schemathesis/internal/diff.py +0 -15
  194. schemathesis/internal/extensions.py +0 -27
  195. schemathesis/internal/jsonschema.py +0 -36
  196. schemathesis/internal/output.py +0 -68
  197. schemathesis/internal/transformation.py +0 -26
  198. schemathesis/internal/validation.py +0 -34
  199. schemathesis/lazy.py +0 -474
  200. schemathesis/loaders.py +0 -122
  201. schemathesis/models.py +0 -1341
  202. schemathesis/parameters.py +0 -90
  203. schemathesis/runner/__init__.py +0 -605
  204. schemathesis/runner/events.py +0 -389
  205. schemathesis/runner/impl/__init__.py +0 -3
  206. schemathesis/runner/impl/context.py +0 -88
  207. schemathesis/runner/impl/core.py +0 -1280
  208. schemathesis/runner/impl/solo.py +0 -80
  209. schemathesis/runner/impl/threadpool.py +0 -391
  210. schemathesis/runner/serialization.py +0 -544
  211. schemathesis/sanitization.py +0 -252
  212. schemathesis/serializers.py +0 -328
  213. schemathesis/service/__init__.py +0 -18
  214. schemathesis/service/auth.py +0 -11
  215. schemathesis/service/ci.py +0 -202
  216. schemathesis/service/client.py +0 -133
  217. schemathesis/service/constants.py +0 -38
  218. schemathesis/service/events.py +0 -61
  219. schemathesis/service/extensions.py +0 -224
  220. schemathesis/service/hosts.py +0 -111
  221. schemathesis/service/metadata.py +0 -71
  222. schemathesis/service/models.py +0 -258
  223. schemathesis/service/report.py +0 -255
  224. schemathesis/service/serialization.py +0 -173
  225. schemathesis/service/usage.py +0 -66
  226. schemathesis/specs/graphql/loaders.py +0 -364
  227. schemathesis/specs/openapi/expressions/context.py +0 -16
  228. schemathesis/specs/openapi/links.py +0 -389
  229. schemathesis/specs/openapi/loaders.py +0 -707
  230. schemathesis/specs/openapi/stateful/statistic.py +0 -198
  231. schemathesis/specs/openapi/stateful/types.py +0 -14
  232. schemathesis/specs/openapi/validation.py +0 -26
  233. schemathesis/stateful/__init__.py +0 -147
  234. schemathesis/stateful/config.py +0 -97
  235. schemathesis/stateful/context.py +0 -135
  236. schemathesis/stateful/events.py +0 -274
  237. schemathesis/stateful/runner.py +0 -309
  238. schemathesis/stateful/sink.py +0 -68
  239. schemathesis/stateful/state_machine.py +0 -328
  240. schemathesis/stateful/statistic.py +0 -22
  241. schemathesis/stateful/validation.py +0 -100
  242. schemathesis/targets.py +0 -77
  243. schemathesis/transports/__init__.py +0 -369
  244. schemathesis/transports/asgi.py +0 -7
  245. schemathesis/transports/auth.py +0 -38
  246. schemathesis/transports/headers.py +0 -36
  247. schemathesis/transports/responses.py +0 -57
  248. schemathesis/types.py +0 -44
  249. schemathesis/utils.py +0 -164
  250. schemathesis-3.39.15.dist-info/METADATA +0 -293
  251. schemathesis-3.39.15.dist-info/RECORD +0 -160
  252. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  253. /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
  254. /schemathesis/{internal → core}/result.py +0 -0
  255. {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -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, ValueError):
110
+ return UNRESOLVABLE
111
+ else:
112
+ return UNRESOLVABLE
113
+ return target
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from typing import TYPE_CHECKING, Any, Mapping
6
+
7
+ from schemathesis.core.version import SCHEMATHESIS_VERSION
8
+
9
+ if TYPE_CHECKING:
10
+ import httpx
11
+ import requests
12
+ from werkzeug.test import TestResponse
13
+
14
+ from schemathesis.generation.overrides import Override
15
+
16
+ USER_AGENT = f"schemathesis/{SCHEMATHESIS_VERSION}"
17
+ DEFAULT_RESPONSE_TIMEOUT = 10
18
+
19
+
20
+ def prepare_urlencoded(data: Any) -> Any:
21
+ if isinstance(data, list):
22
+ output = []
23
+ for item in data:
24
+ if isinstance(item, dict):
25
+ for key, value in item.items():
26
+ output.append((key, value))
27
+ else:
28
+ output.append((item, "arbitrary-value"))
29
+ return output
30
+ return data
31
+
32
+
33
+ class Response:
34
+ """HTTP response wrapper that normalizes different transport implementations.
35
+
36
+ Provides a consistent interface for accessing response data whether the request
37
+ was made via HTTP, ASGI, or WSGI transports.
38
+ """
39
+
40
+ status_code: int
41
+ """HTTP status code (e.g., 200, 404, 500)."""
42
+ headers: dict[str, list[str]]
43
+ """Response headers with lowercase keys and list values."""
44
+ content: bytes
45
+ """Raw response body as bytes."""
46
+ request: requests.PreparedRequest
47
+ """The request that generated this response."""
48
+ elapsed: float
49
+ """Response time in seconds."""
50
+ verify: bool
51
+ """Whether TLS verification was enabled for the request."""
52
+ message: str
53
+ """HTTP status message (e.g., "OK", "Not Found")."""
54
+ http_version: str
55
+ """HTTP protocol version ("1.0" or "1.1")."""
56
+ encoding: str | None
57
+ """Character encoding for text content, if detected."""
58
+ _override: Override | None
59
+
60
+ __slots__ = (
61
+ "status_code",
62
+ "headers",
63
+ "content",
64
+ "request",
65
+ "elapsed",
66
+ "verify",
67
+ "_json",
68
+ "message",
69
+ "http_version",
70
+ "encoding",
71
+ "_encoded_body",
72
+ "_override",
73
+ )
74
+
75
+ def __init__(
76
+ self,
77
+ status_code: int,
78
+ headers: Mapping[str, list[str]],
79
+ content: bytes,
80
+ request: requests.PreparedRequest,
81
+ elapsed: float,
82
+ verify: bool,
83
+ message: str = "",
84
+ http_version: str = "1.1",
85
+ encoding: str | None = None,
86
+ _override: Override | None = None,
87
+ ):
88
+ self.status_code = status_code
89
+ self.headers = {key.lower(): value for key, value in headers.items()}
90
+ assert all(isinstance(v, list) for v in headers.values())
91
+ self.content = content
92
+ self.request = request
93
+ self.elapsed = elapsed
94
+ self.verify = verify
95
+ self._json = None
96
+ self._encoded_body: str | None = None
97
+ self.message = message
98
+ self.http_version = http_version
99
+ self.encoding = encoding
100
+ self._override = _override
101
+
102
+ @classmethod
103
+ def from_any(cls, response: Response | httpx.Response | requests.Response | TestResponse) -> Response:
104
+ import httpx
105
+ import requests
106
+ from werkzeug.test import TestResponse
107
+
108
+ if isinstance(response, requests.Response):
109
+ return Response.from_requests(response, verify=True)
110
+ elif isinstance(response, httpx.Response):
111
+ return Response.from_httpx(response, verify=True)
112
+ elif isinstance(response, TestResponse):
113
+ return Response.from_wsgi(response)
114
+ return response
115
+
116
+ @classmethod
117
+ def from_requests(cls, response: requests.Response, verify: bool, _override: Override | None = None) -> Response:
118
+ raw = response.raw
119
+ raw_headers = raw.headers if raw is not None else {}
120
+ headers = {name: response.raw.headers.getlist(name) for name in raw_headers.keys()}
121
+ # Similar to http.client:319 (HTTP version detection in stdlib's `http` package)
122
+ version = raw.version if raw is not None else 10
123
+ http_version = "1.0" if version == 10 else "1.1"
124
+ return Response(
125
+ status_code=response.status_code,
126
+ headers=headers,
127
+ content=response.content,
128
+ request=response.request,
129
+ elapsed=response.elapsed.total_seconds(),
130
+ message=response.reason,
131
+ encoding=response.encoding,
132
+ http_version=http_version,
133
+ verify=verify,
134
+ _override=_override,
135
+ )
136
+
137
+ @classmethod
138
+ def from_httpx(cls, response: httpx.Response, verify: bool) -> Response:
139
+ import requests
140
+
141
+ request = requests.Request(
142
+ method=response.request.method,
143
+ url=str(response.request.url),
144
+ headers=dict(response.request.headers),
145
+ params=dict(response.request.url.params),
146
+ data=response.request.content,
147
+ ).prepare()
148
+ return Response(
149
+ status_code=response.status_code,
150
+ headers={key: [value] for key, value in response.headers.items()},
151
+ content=response.content,
152
+ request=request,
153
+ elapsed=response.elapsed.total_seconds(),
154
+ message=response.reason_phrase,
155
+ encoding=response.encoding,
156
+ http_version=response.http_version,
157
+ verify=verify,
158
+ )
159
+
160
+ @classmethod
161
+ def from_wsgi(cls, response: TestResponse) -> Response:
162
+ import http.client
163
+
164
+ import requests
165
+
166
+ reason = http.client.responses.get(response.status_code, "Unknown")
167
+ data = response.get_data()
168
+ if response.response == []:
169
+ # Werkzeug <3.0 had `charset` attr, newer versions always have UTF-8
170
+ encoding = response.mimetype_params.get("charset", getattr(response, "charset", "utf-8"))
171
+ else:
172
+ encoding = None
173
+ request = requests.Request(
174
+ method=response.request.method,
175
+ url=str(response.request.url),
176
+ headers=dict(response.request.headers),
177
+ params=dict(response.request.args),
178
+ # Request body is not available
179
+ data=b"",
180
+ ).prepare()
181
+ return Response(
182
+ status_code=response.status_code,
183
+ headers={name: response.headers.getlist(name) for name in response.headers.keys()},
184
+ content=data,
185
+ request=request,
186
+ # Elapsed time is not available
187
+ elapsed=0.0,
188
+ message=reason,
189
+ encoding=encoding,
190
+ http_version="1.1",
191
+ verify=False,
192
+ )
193
+
194
+ @property
195
+ def text(self) -> str:
196
+ """Decode response content as text using the detected or default encoding."""
197
+ return self.content.decode(self.encoding if self.encoding else "utf-8")
198
+
199
+ def json(self) -> Any:
200
+ """Parse response content as JSON.
201
+
202
+ Returns:
203
+ Parsed JSON data (dict, list, or primitive types)
204
+
205
+ Raises:
206
+ json.JSONDecodeError: If content is not valid JSON
207
+
208
+ """
209
+ if self._json is None:
210
+ self._json = json.loads(self.text)
211
+ return self._json
212
+
213
+ @property
214
+ def body_size(self) -> int | None:
215
+ """Size of response body in bytes, or None if no content."""
216
+ return len(self.content) if self.content else None
217
+
218
+ @property
219
+ def encoded_body(self) -> str | None:
220
+ """Base64-encoded response body for binary-safe serialization."""
221
+ if self._encoded_body is None and self.content:
222
+ self._encoded_body = base64.b64encode(self.content).decode()
223
+ return self._encoded_body
@@ -0,0 +1,54 @@
1
+ import re
2
+ from urllib.parse import urlparse
3
+
4
+ # Adapted from http.client._is_illegal_header_value
5
+ INVALID_HEADER_RE = re.compile(r"\n(?![ \t])|\r(?![ \t\n])")
6
+
7
+
8
+ def has_invalid_characters(name: str, value: object) -> bool:
9
+ from requests.exceptions import InvalidHeader
10
+ from requests.utils import check_header_validity
11
+
12
+ if not isinstance(value, str):
13
+ return False
14
+ try:
15
+ check_header_validity((name, value))
16
+ return bool(INVALID_HEADER_RE.search(value))
17
+ except InvalidHeader:
18
+ return True
19
+
20
+
21
+ def is_latin_1_encodable(value: object) -> bool:
22
+ """Check if a value is a Latin-1 encodable string."""
23
+ if not isinstance(value, str):
24
+ return False
25
+ try:
26
+ value.encode("latin-1")
27
+ return True
28
+ except UnicodeEncodeError:
29
+ return False
30
+
31
+
32
+ SURROGATE_PAIR_RE = re.compile(r"[\ud800-\udfff]")
33
+ _contains_surrogate_pair = SURROGATE_PAIR_RE.search
34
+
35
+
36
+ def contains_unicode_surrogate_pair(item: object) -> bool:
37
+ if isinstance(item, list):
38
+ return any(isinstance(item_, str) and bool(_contains_surrogate_pair(item_)) for item_ in item)
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)
@@ -0,0 +1,7 @@
1
+ from importlib import metadata
2
+
3
+ try:
4
+ SCHEMATHESIS_VERSION = metadata.version(__package__)
5
+ except metadata.PackageNotFoundError:
6
+ # Local run without installation
7
+ SCHEMATHESIS_VERSION = "dev"
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from schemathesis.engine.core import Engine
8
+ from schemathesis.schemas import BaseSchema
9
+
10
+
11
+ class Status(str, Enum):
12
+ SUCCESS = "success"
13
+ FAILURE = "failure"
14
+ ERROR = "error"
15
+ INTERRUPTED = "interrupted"
16
+ SKIP = "skip"
17
+
18
+ def __lt__(self, other: Status) -> bool: # type: ignore[override]
19
+ return _STATUS_ORDER[self] < _STATUS_ORDER[other]
20
+
21
+
22
+ _STATUS_ORDER = {Status.SUCCESS: 0, Status.FAILURE: 1, Status.ERROR: 2, Status.INTERRUPTED: 3, Status.SKIP: 4}
23
+
24
+
25
+ def from_schema(schema: BaseSchema) -> Engine:
26
+ from .core import Engine
27
+
28
+ return Engine(schema=schema)
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from schemathesis.config import ProjectConfig
8
+ from schemathesis.core import NOT_SET, NotSet
9
+ from schemathesis.generation.case import Case
10
+ from schemathesis.schemas import APIOperation, BaseSchema
11
+
12
+ from .control import ExecutionControl
13
+
14
+ if TYPE_CHECKING:
15
+ import threading
16
+
17
+ import requests
18
+
19
+
20
+ @dataclass
21
+ class EngineContext:
22
+ """Holds context shared for a test run."""
23
+
24
+ schema: BaseSchema
25
+ control: ExecutionControl
26
+ outcome_cache: dict[int, BaseException | None]
27
+ start_time: float
28
+
29
+ __slots__ = ("schema", "control", "outcome_cache", "start_time", "_session", "_transport_kwargs_cache")
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ schema: BaseSchema,
35
+ stop_event: threading.Event,
36
+ session: requests.Session | None = None,
37
+ ) -> None:
38
+ self.schema = schema
39
+ self.control = ExecutionControl(stop_event=stop_event, max_failures=schema.config.max_failures)
40
+ self.outcome_cache = {}
41
+ self.start_time = time.monotonic()
42
+ self._session = session
43
+ self._transport_kwargs_cache: dict[str | None, dict[str, Any]] = {}
44
+
45
+ def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
46
+
47
+ @property
48
+ def config(self) -> ProjectConfig:
49
+ return self.schema.config
50
+
51
+ @property
52
+ def running_time(self) -> float:
53
+ return time.monotonic() - self.start_time
54
+
55
+ @property
56
+ def has_to_stop(self) -> bool:
57
+ """Check if execution should stop."""
58
+ return self.control.is_stopped
59
+
60
+ @property
61
+ def is_interrupted(self) -> bool:
62
+ return self.control.is_interrupted
63
+
64
+ @property
65
+ def has_reached_the_failure_limit(self) -> bool:
66
+ return self.control.has_reached_the_failure_limit
67
+
68
+ def stop(self) -> None:
69
+ self.control.stop()
70
+
71
+ def cache_outcome(self, case: Case, outcome: BaseException | None) -> None:
72
+ self.outcome_cache[hash(case)] = outcome
73
+
74
+ def get_cached_outcome(self, case: Case) -> BaseException | None | NotSet:
75
+ return self.outcome_cache.get(hash(case), NOT_SET)
76
+
77
+ def get_session(self, *, operation: APIOperation | None = None) -> requests.Session:
78
+ if self._session is not None:
79
+ return self._session
80
+ import requests
81
+
82
+ session = requests.Session()
83
+ session.headers = {}
84
+ config = self.config
85
+
86
+ session.verify = config.tls_verify_for(operation=operation)
87
+ auth = config.auth_for(operation=operation)
88
+ if auth is not None:
89
+ session.auth = auth
90
+ headers = config.headers_for(operation=operation)
91
+ if headers:
92
+ session.headers.update(headers)
93
+ request_cert = config.request_cert_for(operation=operation)
94
+ if request_cert is not None:
95
+ session.cert = request_cert
96
+ proxy = config.proxy_for(operation=operation)
97
+ if proxy is not None:
98
+ session.proxies["all"] = proxy
99
+ return session
100
+
101
+ def get_transport_kwargs(self, operation: APIOperation | None = None) -> dict[str, Any]:
102
+ key = operation.label if operation is not None else None
103
+ cached = self._transport_kwargs_cache.get(key)
104
+ if cached is not None:
105
+ return cached.copy()
106
+ config = self.config
107
+ kwargs: dict[str, Any] = {
108
+ "session": self.get_session(operation=operation),
109
+ "headers": config.headers_for(operation=operation),
110
+ "timeout": config.request_timeout_for(operation=operation),
111
+ "verify": config.tls_verify_for(operation=operation),
112
+ "cert": config.request_cert_for(operation=operation),
113
+ }
114
+ proxy = config.proxy_for(operation=operation)
115
+ if proxy is not None:
116
+ kwargs["proxies"] = {"all": proxy}
117
+ self._transport_kwargs_cache[key] = kwargs
118
+ return kwargs
@@ -0,0 +1,36 @@
1
+ """Control for the Schemathesis Engine execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class ExecutionControl:
11
+ """Controls engine execution flow and tracks failures."""
12
+
13
+ stop_event: threading.Event
14
+ max_failures: int | None
15
+ _failures_counter: int = 0
16
+ has_reached_the_failure_limit: bool = False
17
+
18
+ @property
19
+ def is_stopped(self) -> bool:
20
+ """Check if execution should stop."""
21
+ return self.is_interrupted or self.has_reached_the_failure_limit
22
+
23
+ @property
24
+ def is_interrupted(self) -> bool:
25
+ return self.stop_event.is_set()
26
+
27
+ def stop(self) -> None:
28
+ """Signal to stop execution."""
29
+ self.stop_event.set()
30
+
31
+ def count_failure(self) -> None:
32
+ # N failures limit
33
+ if self.max_failures is not None:
34
+ self._failures_counter += 1
35
+ if self._failures_counter >= self.max_failures:
36
+ self.has_reached_the_failure_limit = True