schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__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 (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1766
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{cli → engine/phases}/probes.py +63 -70
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +153 -39
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +483 -367
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -55
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -765
  156. schemathesis/cli/output/short.py +0 -40
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1231
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -555
  184. schemathesis/runner/events.py +0 -309
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -986
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -315
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -184
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.5.dist-info/METADATA +0 -356
  219. schemathesis-3.25.5.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,69 @@
1
+ """Support for Targeted Property-Based Testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, Sequence
7
+
8
+ from schemathesis.core.registries import Registry
9
+ from schemathesis.core.transport import Response
10
+ from schemathesis.generation.case import Case
11
+
12
+
13
+ @dataclass
14
+ class TargetContext:
15
+ case: Case
16
+ response: Response
17
+
18
+ __slots__ = ("case", "response")
19
+
20
+
21
+ TargetFunction = Callable[[TargetContext], float]
22
+
23
+ TARGETS = Registry[TargetFunction]()
24
+ target = TARGETS.register
25
+
26
+
27
+ @target
28
+ def response_time(ctx: TargetContext) -> float:
29
+ """Response time as a metric to maximize."""
30
+ return ctx.response.elapsed
31
+
32
+
33
+ class TargetMetricCollector:
34
+ """Collect multiple observations for target metrics."""
35
+
36
+ __slots__ = ("targets", "observations")
37
+
38
+ def __init__(self, targets: list[TargetFunction] | None = None) -> None:
39
+ self.targets = targets or []
40
+ self.observations: dict[str, list[float]] = {target.__name__: [] for target in self.targets}
41
+
42
+ def reset(self) -> None:
43
+ """Reset all collected observations."""
44
+ for target in self.targets:
45
+ self.observations[target.__name__].clear()
46
+
47
+ def store(self, case: Case, response: Response) -> None:
48
+ """Calculate target metrics & store them."""
49
+ context = TargetContext(case=case, response=response)
50
+ for target in self.targets:
51
+ self.observations[target.__name__].append(target(context))
52
+
53
+ def maximize(self) -> None:
54
+ """Give feedback to the Hypothesis engine, so it maximizes the aggregated metrics."""
55
+ import hypothesis
56
+
57
+ for target in self.targets:
58
+ # Currently aggregation is just a sum
59
+ metric = sum(self.observations[target.__name__])
60
+ hypothesis.target(metric, label=target.__name__)
61
+
62
+
63
+ def run(targets: Sequence[TargetFunction], case: Case, response: Response) -> None:
64
+ import hypothesis
65
+
66
+ context = TargetContext(case=case, response=response)
67
+ for target in targets:
68
+ value = target(context)
69
+ hypothesis.target(value, label=target.__name__)
@@ -0,0 +1,15 @@
1
+ from schemathesis.graphql.loaders import from_asgi, from_dict, from_file, from_path, from_url, from_wsgi
2
+
3
+ from ..specs.graphql import nodes
4
+ from ..specs.graphql.scalars import scalar
5
+
6
+ __all__ = [
7
+ "from_url",
8
+ "from_asgi",
9
+ "from_wsgi",
10
+ "from_file",
11
+ "from_path",
12
+ "from_dict",
13
+ "nodes",
14
+ "scalar",
15
+ ]
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from schemathesis.core.failures import Failure, Severity
6
+
7
+ if TYPE_CHECKING:
8
+ from graphql.error import GraphQLFormattedError
9
+
10
+
11
+ class UnexpectedGraphQLResponse(Failure):
12
+ """GraphQL response is not a JSON object."""
13
+
14
+ __slots__ = ("operation", "type_name", "title", "message", "code", "case_id", "severity")
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ operation: str,
20
+ type_name: str,
21
+ title: str = "Unexpected GraphQL Response",
22
+ message: str,
23
+ code: str = "graphql_unexpected_response",
24
+ case_id: str | None = None,
25
+ ) -> None:
26
+ self.operation = operation
27
+ self.type_name = type_name
28
+ self.title = title
29
+ self.message = message
30
+ self.code = code
31
+ self.case_id = case_id
32
+ self.severity = Severity.MEDIUM
33
+
34
+ @property
35
+ def _unique_key(self) -> str:
36
+ return self.type_name
37
+
38
+
39
+ class GraphQLClientError(Failure):
40
+ """GraphQL query has not been executed."""
41
+
42
+ __slots__ = ("operation", "errors", "title", "message", "code", "case_id", "_unique_key_cache", "severity")
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ operation: str,
48
+ message: str,
49
+ errors: list[GraphQLFormattedError],
50
+ title: str = "GraphQL client error",
51
+ code: str = "graphql_client_error",
52
+ case_id: str | None = None,
53
+ ) -> None:
54
+ self.operation = operation
55
+ self.errors = errors
56
+ self.title = title
57
+ self.message = message
58
+ self.code = code
59
+ self.case_id = case_id
60
+ self._unique_key_cache: str | None = None
61
+ self.severity = Severity.MEDIUM
62
+
63
+ @property
64
+ def _unique_key(self) -> str:
65
+ if self._unique_key_cache is None:
66
+ self._unique_key_cache = _group_graphql_errors(self.errors)
67
+ return self._unique_key_cache
68
+
69
+
70
+ class GraphQLServerError(Failure):
71
+ """GraphQL response indicates at least one server error."""
72
+
73
+ __slots__ = ("operation", "errors", "title", "message", "code", "case_id", "_unique_key_cache", "severity")
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ operation: str,
79
+ message: str,
80
+ errors: list[GraphQLFormattedError],
81
+ title: str = "GraphQL server error",
82
+ code: str = "graphql_server_error",
83
+ case_id: str | None = None,
84
+ ) -> None:
85
+ self.operation = operation
86
+ self.errors = errors
87
+ self.title = title
88
+ self.message = message
89
+ self.code = code
90
+ self.case_id = case_id
91
+ self._unique_key_cache: str | None = None
92
+ self.severity = Severity.CRITICAL
93
+
94
+ @property
95
+ def _unique_key(self) -> str:
96
+ if self._unique_key_cache is None:
97
+ self._unique_key_cache = _group_graphql_errors(self.errors)
98
+ return self._unique_key_cache
99
+
100
+
101
+ def _group_graphql_errors(errors: list[GraphQLFormattedError]) -> str:
102
+ entries = []
103
+ for error in errors:
104
+ message = error["message"]
105
+ if "locations" in error:
106
+ message += ";locations:"
107
+ for location in sorted(error["locations"]):
108
+ message += f"({location['line'], location['column']})"
109
+ if "path" in error:
110
+ message += ";path:"
111
+ for chunk in error["path"]:
112
+ message += str(chunk)
113
+ entries.append(message)
114
+ entries.sort()
115
+ return "".join(entries)
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from functools import lru_cache
5
+ from os import PathLike
6
+ from pathlib import Path
7
+ from typing import IO, TYPE_CHECKING, Any, Callable, Dict, NoReturn, TypeVar, cast
8
+
9
+ from schemathesis.core.errors import LoaderError, LoaderErrorKind
10
+ from schemathesis.core.loaders import load_from_url, prepare_request_kwargs, raise_for_status, require_relative_url
11
+ from schemathesis.hooks import HookContext, dispatch
12
+ from schemathesis.python import asgi, wsgi
13
+
14
+ if TYPE_CHECKING:
15
+ from graphql import DocumentNode
16
+
17
+ from schemathesis.specs.graphql.schemas import GraphQLSchema
18
+
19
+
20
+ def from_asgi(path: str, app: Any, **kwargs: Any) -> GraphQLSchema:
21
+ require_relative_url(path)
22
+ kwargs.setdefault("json", {"query": get_introspection_query()})
23
+ client = asgi.get_client(app)
24
+ response = load_from_url(client.post, url=path, **kwargs)
25
+ schema = extract_schema_from_response(response, lambda r: r.json())
26
+ return from_dict(schema=schema).configure(app=app, location=path)
27
+
28
+
29
+ def from_wsgi(path: str, app: Any, **kwargs: Any) -> GraphQLSchema:
30
+ require_relative_url(path)
31
+ prepare_request_kwargs(kwargs)
32
+ kwargs.setdefault("json", {"query": get_introspection_query()})
33
+ client = wsgi.get_client(app)
34
+ response = client.post(path=path, **kwargs)
35
+ raise_for_status(response)
36
+ schema = extract_schema_from_response(response, lambda r: r.json)
37
+ return from_dict(schema=schema).configure(app=app, location=path)
38
+
39
+
40
+ def from_url(url: str, *, wait_for_schema: float | None = None, **kwargs: Any) -> GraphQLSchema:
41
+ """Load from URL."""
42
+ import requests
43
+
44
+ kwargs.setdefault("json", {"query": get_introspection_query()})
45
+ response = load_from_url(requests.post, url=url, wait_for_schema=wait_for_schema, **kwargs)
46
+ schema = extract_schema_from_response(response, lambda r: r.json())
47
+ return from_dict(schema).configure(location=url)
48
+
49
+
50
+ def from_path(path: PathLike | str, *, encoding: str = "utf-8") -> GraphQLSchema:
51
+ """Load from a filesystem path."""
52
+ with open(path, encoding=encoding) as file:
53
+ return from_file(file=file).configure(location=Path(path).absolute().as_uri())
54
+
55
+
56
+ def from_file(file: IO[str] | str) -> GraphQLSchema:
57
+ """Load from file-like object or string."""
58
+ import graphql
59
+
60
+ if isinstance(file, str):
61
+ data = file
62
+ else:
63
+ data = file.read()
64
+ try:
65
+ document = graphql.build_schema(data)
66
+ result = graphql.execute(document, get_introspection_query_ast())
67
+ # TYPES: We don't pass `is_awaitable` above, therefore `result` is of the `ExecutionResult` type
68
+ result = cast(graphql.ExecutionResult, result)
69
+ # TYPES:
70
+ # - `document` is a valid schema, because otherwise `build_schema` will rise an error;
71
+ # - `INTROSPECTION_QUERY` is a valid query - it is known upfront;
72
+ # Therefore the execution result is always valid at this point and `result.data` is not `None`
73
+ schema = cast(Dict[str, Any], result.data)
74
+ except Exception as exc:
75
+ try:
76
+ schema = json.loads(data)
77
+ if not isinstance(schema, dict) or "__schema" not in schema:
78
+ _on_invalid_schema(exc)
79
+ except json.JSONDecodeError:
80
+ _on_invalid_schema(exc, extras=[entry for entry in str(exc).splitlines() if entry])
81
+ return from_dict(schema)
82
+
83
+
84
+ def from_dict(schema: dict[str, Any]) -> GraphQLSchema:
85
+ """Base loader that others build upon."""
86
+ from schemathesis.specs.graphql.schemas import GraphQLSchema
87
+
88
+ if "data" in schema:
89
+ schema = schema["data"]
90
+ hook_context = HookContext()
91
+ dispatch("before_load_schema", hook_context, schema)
92
+ instance = GraphQLSchema(schema)
93
+ dispatch("after_load_schema", hook_context, instance)
94
+ return instance
95
+
96
+
97
+ @lru_cache
98
+ def get_introspection_query() -> str:
99
+ import graphql
100
+
101
+ return graphql.get_introspection_query()
102
+
103
+
104
+ @lru_cache
105
+ def get_introspection_query_ast() -> DocumentNode:
106
+ import graphql
107
+
108
+ query = get_introspection_query()
109
+ return graphql.parse(query)
110
+
111
+
112
+ R = TypeVar("R")
113
+
114
+
115
+ def extract_schema_from_response(response: R, callback: Callable[[R], Any]) -> dict[str, Any]:
116
+ try:
117
+ decoded = callback(response)
118
+ except json.JSONDecodeError as exc:
119
+ raise LoaderError(
120
+ LoaderErrorKind.UNEXPECTED_CONTENT_TYPE,
121
+ "Received unsupported content while expecting a JSON payload for GraphQL",
122
+ ) from exc
123
+ return decoded
124
+
125
+
126
+ def _on_invalid_schema(exc: Exception, extras: list[str] | None = None) -> NoReturn:
127
+ raise LoaderError(
128
+ LoaderErrorKind.GRAPHQL_INVALID_SCHEMA,
129
+ "The provided API schema does not appear to be a valid GraphQL schema",
130
+ extras=extras or [],
131
+ ) from exc
schemathesis/hooks.py CHANGED
@@ -1,20 +1,23 @@
1
1
  from __future__ import annotations
2
+
2
3
  import inspect
3
4
  from collections import defaultdict
4
- from copy import deepcopy
5
5
  from dataclasses import dataclass, field
6
6
  from enum import Enum, unique
7
7
  from functools import partial
8
- from typing import TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, cast
8
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast
9
9
 
10
- from .types import GenericTest
11
- from .internal.deprecation import deprecated_property
10
+ from schemathesis.core.marks import Mark
11
+ from schemathesis.core.transport import Response
12
+ from schemathesis.filters import FilterSet, attach_filter_chain
12
13
 
13
14
  if TYPE_CHECKING:
14
15
  from hypothesis import strategies as st
15
- from .models import APIOperation, Case
16
- from .schemas import BaseSchema
17
- from .transports.responses import GenericResponse
16
+
17
+ from schemathesis.generation.case import Case
18
+ from schemathesis.schemas import APIOperation, BaseSchema
19
+
20
+ HookDispatcherMark = Mark["HookDispatcher"](attr_name="hook_dispatcher")
18
21
 
19
22
 
20
23
  @unique
@@ -29,6 +32,8 @@ class RegisteredHook:
29
32
  signature: inspect.Signature
30
33
  scopes: list[HookScope]
31
34
 
35
+ def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
36
+
32
37
 
33
38
  @dataclass
34
39
  class HookContext:
@@ -40,9 +45,57 @@ class HookContext:
40
45
 
41
46
  operation: APIOperation | None = None
42
47
 
43
- @deprecated_property(removed_in="4.0", replacement="operation")
44
- def endpoint(self) -> APIOperation | None:
45
- return self.operation
48
+
49
+ def to_filterable_hook(dispatcher: HookDispatcher) -> Callable:
50
+ filter_used = False
51
+ filter_set = FilterSet()
52
+
53
+ def register(hook: str | Callable) -> Callable:
54
+ nonlocal filter_set
55
+
56
+ if filter_used:
57
+ validate_filterable_hook(hook)
58
+
59
+ if isinstance(hook, str):
60
+
61
+ def decorator(func: Callable) -> Callable:
62
+ hook_name = cast(str, hook)
63
+ if filter_used:
64
+ validate_filterable_hook(hook)
65
+ func.filter_set = filter_set # type: ignore[attr-defined]
66
+ return dispatcher.register_hook_with_name(func, hook_name)
67
+
68
+ init_filter_set(decorator)
69
+ return decorator
70
+
71
+ hook.filter_set = filter_set # type: ignore[attr-defined]
72
+ init_filter_set(register)
73
+ return dispatcher.register_hook_with_name(hook, hook.__name__)
74
+
75
+ def init_filter_set(target: Callable) -> FilterSet:
76
+ nonlocal filter_used
77
+
78
+ filter_used = False
79
+ filter_set = FilterSet()
80
+
81
+ def include(*args: Any, **kwargs: Any) -> None:
82
+ nonlocal filter_used
83
+
84
+ filter_used = True
85
+ filter_set.include(*args, **kwargs)
86
+
87
+ def exclude(*args: Any, **kwargs: Any) -> None:
88
+ nonlocal filter_used
89
+
90
+ filter_used = True
91
+ filter_set.exclude(*args, **kwargs)
92
+
93
+ attach_filter_chain(target, "apply_to", include)
94
+ attach_filter_chain(target, "skip_for", exclude)
95
+ return filter_set
96
+
97
+ filter_set = init_filter_set(register)
98
+ return register
46
99
 
47
100
 
48
101
  @dataclass
@@ -53,9 +106,12 @@ class HookDispatcher:
53
106
  """
54
107
 
55
108
  scope: HookScope
56
- _hooks: DefaultDict[str, list[Callable]] = field(default_factory=lambda: defaultdict(list))
109
+ _hooks: defaultdict[str, list[Callable]] = field(default_factory=lambda: defaultdict(list))
57
110
  _specs: ClassVar[dict[str, RegisteredHook]] = {}
58
111
 
112
+ def __post_init__(self) -> None:
113
+ self.register = to_filterable_hook(self) # type: ignore[method-assign]
114
+
59
115
  def register(self, hook: str | Callable) -> Callable:
60
116
  """Register a new hook.
61
117
 
@@ -78,26 +134,7 @@ class HookDispatcher:
78
134
  def hook(context, strategy):
79
135
  ...
80
136
  """
81
- if isinstance(hook, str):
82
-
83
- def decorator(func: Callable) -> Callable:
84
- hook_name = cast(str, hook)
85
- return self.register_hook_with_name(func, hook_name)
86
-
87
- return decorator
88
- return self.register_hook_with_name(hook, hook.__name__)
89
-
90
- def merge(self, other: HookDispatcher) -> HookDispatcher:
91
- """Merge two dispatches together.
92
-
93
- The resulting dispatcher will call the `self` hooks first.
94
- """
95
- all_hooks = deepcopy(self._hooks)
96
- for name, hooks in other._hooks.items():
97
- all_hooks[name].extend(hooks)
98
- instance = self.__class__(scope=self.scope)
99
- instance._hooks = all_hooks
100
- return instance
137
+ raise NotImplementedError
101
138
 
102
139
  def apply(self, hook: Callable, *, name: str | None = None) -> Callable[[Callable], Callable]:
103
140
  """Register hook to run only on one test function.
@@ -122,7 +159,7 @@ class HookDispatcher:
122
159
  else:
123
160
  hook_name = name
124
161
 
125
- def decorator(func: GenericTest) -> GenericTest:
162
+ def decorator(func: Callable) -> Callable:
126
163
  dispatcher = self.add_dispatcher(func)
127
164
  dispatcher.register_hook_with_name(hook, hook_name)
128
165
  return func
@@ -130,11 +167,13 @@ class HookDispatcher:
130
167
  return decorator
131
168
 
132
169
  @classmethod
133
- def add_dispatcher(cls, func: GenericTest) -> HookDispatcher:
170
+ def add_dispatcher(cls, func: Callable) -> HookDispatcher:
134
171
  """Attach a new dispatcher instance to the test if it is not already present."""
135
- if not hasattr(func, "_schemathesis_hooks"):
136
- func._schemathesis_hooks = cls(scope=HookScope.TEST) # type: ignore
137
- return func._schemathesis_hooks # type: ignore
172
+ if not HookDispatcherMark.is_set(func):
173
+ HookDispatcherMark.set(func, cls(scope=HookScope.TEST))
174
+ dispatcher = HookDispatcherMark.get(func)
175
+ assert dispatcher is not None
176
+ return dispatcher
138
177
 
139
178
  def register_hook_with_name(self, hook: Callable, name: str) -> Callable:
140
179
  """A helper for hooks registration."""
@@ -173,31 +212,30 @@ class HookDispatcher:
173
212
  f"Hook '{name}' takes {len(spec.signature.parameters)} arguments but {len(signature.parameters)} is defined"
174
213
  )
175
214
 
176
- def collect_statistic(self) -> dict[str, int]:
177
- return {name: len(hooks) for name, hooks in self._hooks.items()}
178
-
179
215
  def get_all_by_name(self, name: str) -> list[Callable]:
180
216
  """Get a list of hooks registered for a name."""
181
217
  return self._hooks.get(name, [])
182
218
 
183
- def is_installed(self, name: str, needle: Callable) -> bool:
184
- for hook in self.get_all_by_name(name):
185
- if hook is needle:
186
- return True
187
- return False
188
-
189
219
  def apply_to_container(
190
220
  self, strategy: st.SearchStrategy, container: str, context: HookContext
191
221
  ) -> st.SearchStrategy:
192
222
  for hook in self.get_all_by_name(f"before_generate_{container}"):
223
+ if _should_skip_hook(hook, context):
224
+ continue
193
225
  strategy = hook(context, strategy)
194
226
  for hook in self.get_all_by_name(f"filter_{container}"):
227
+ if _should_skip_hook(hook, context):
228
+ continue
195
229
  hook = partial(hook, context)
196
230
  strategy = strategy.filter(hook)
197
231
  for hook in self.get_all_by_name(f"map_{container}"):
232
+ if _should_skip_hook(hook, context):
233
+ continue
198
234
  hook = partial(hook, context)
199
235
  strategy = strategy.map(hook)
200
236
  for hook in self.get_all_by_name(f"flatmap_{container}"):
237
+ if _should_skip_hook(hook, context):
238
+ continue
201
239
  hook = partial(hook, context)
202
240
  strategy = strategy.flatmap(hook)
203
241
  return strategy
@@ -205,6 +243,8 @@ class HookDispatcher:
205
243
  def dispatch(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None:
206
244
  """Run all hooks for the given name."""
207
245
  for hook in self.get_all_by_name(name):
246
+ if _should_skip_hook(hook, context):
247
+ continue
208
248
  hook(context, *args, **kwargs)
209
249
 
210
250
  def unregister(self, hook: Callable) -> None:
@@ -224,6 +264,11 @@ class HookDispatcher:
224
264
  self._hooks = defaultdict(list)
225
265
 
226
266
 
267
+ def _should_skip_hook(hook: Callable, ctx: HookContext) -> bool:
268
+ filter_set = getattr(hook, "filter_set", None)
269
+ return filter_set is not None and ctx.operation is not None and not filter_set.match(ctx)
270
+
271
+
227
272
  def apply_to_all_dispatchers(
228
273
  operation: APIOperation,
229
274
  context: HookContext,
@@ -239,11 +284,13 @@ def apply_to_all_dispatchers(
239
284
  return strategy
240
285
 
241
286
 
242
- def should_skip_operation(dispatcher: HookDispatcher, context: HookContext) -> bool:
243
- for hook in dispatcher.get_all_by_name("filter_operations"):
244
- if not hook(context):
245
- return True
246
- return False
287
+ def validate_filterable_hook(hook: str | Callable) -> None:
288
+ if callable(hook):
289
+ name = hook.__name__
290
+ else:
291
+ name = hook
292
+ if name in ("before_process_path", "before_load_schema", "after_load_schema"):
293
+ raise ValueError(f"Filters are not applicable to this hook: `{name}`")
247
294
 
248
295
 
249
296
  all_scopes = HookDispatcher.register_spec(list(HookScope))
@@ -296,11 +343,6 @@ def before_process_path(context: HookContext, path: str, methods: dict[str, Any]
296
343
  """Called before API path is processed."""
297
344
 
298
345
 
299
- @all_scopes
300
- def filter_operations(context: HookContext) -> bool | None:
301
- """Decide whether testing of this particular API operation should be skipped or not."""
302
-
303
-
304
346
  @HookDispatcher.register_spec([HookScope.GLOBAL])
305
347
  def before_load_schema(context: HookContext, raw_schema: dict[str, Any]) -> None:
306
348
  """Called before schema instance is created."""
@@ -325,15 +367,7 @@ def before_init_operation(context: HookContext, operation: APIOperation) -> None
325
367
 
326
368
 
327
369
  @HookDispatcher.register_spec([HookScope.GLOBAL])
328
- def add_case(context: HookContext, case: Case, response: GenericResponse) -> Case | None:
329
- """Creates an additional test per API operation. If this hook returns None, no additional test created.
330
-
331
- Called with a copy of the original case object and the server's response to the original case.
332
- """
333
-
334
-
335
- @HookDispatcher.register_spec([HookScope.GLOBAL])
336
- def before_call(context: HookContext, case: Case) -> None:
370
+ def before_call(context: HookContext, case: Case, **kwargs: Any) -> None:
337
371
  """Called before every network call in CLI tests.
338
372
 
339
373
  Use cases:
@@ -343,7 +377,7 @@ def before_call(context: HookContext, case: Case) -> None:
343
377
 
344
378
 
345
379
  @HookDispatcher.register_spec([HookScope.GLOBAL])
346
- def after_call(context: HookContext, case: Case, response: GenericResponse) -> None:
380
+ def after_call(context: HookContext, case: Case, response: Response) -> None:
347
381
  """Called after every network call in CLI tests.
348
382
 
349
383
  Note that you need to modify the response in-place.
@@ -357,8 +391,6 @@ def after_call(context: HookContext, case: Case, response: GenericResponse) -> N
357
391
  GLOBAL_HOOK_DISPATCHER = HookDispatcher(scope=HookScope.GLOBAL)
358
392
  dispatch = GLOBAL_HOOK_DISPATCHER.dispatch
359
393
  get_all_by_name = GLOBAL_HOOK_DISPATCHER.get_all_by_name
360
- is_installed = GLOBAL_HOOK_DISPATCHER.is_installed
361
- collect_statistic = GLOBAL_HOOK_DISPATCHER.collect_statistic
362
394
  register = GLOBAL_HOOK_DISPATCHER.register
363
395
  unregister = GLOBAL_HOOK_DISPATCHER.unregister
364
396
  unregister_all = GLOBAL_HOOK_DISPATCHER.unregister_all
@@ -0,0 +1,13 @@
1
+ from schemathesis.openapi.loaders import from_asgi, from_dict, from_file, from_path, from_url, from_wsgi
2
+ from schemathesis.specs.openapi import format, media_type
3
+
4
+ __all__ = [
5
+ "from_url",
6
+ "from_asgi",
7
+ "from_wsgi",
8
+ "from_file",
9
+ "from_path",
10
+ "from_dict",
11
+ "format",
12
+ "media_type",
13
+ ]