schemathesis 3.25.6__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.
- schemathesis/__init__.py +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1760
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +50 -67
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +139 -23
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +478 -369
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -58
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -790
- schemathesis/cli/output/short.py +0 -44
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1234
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -570
- schemathesis/runner/events.py +0 -329
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -1035
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -323
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -199
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.6.dist-info/METADATA +0 -356
- schemathesis-3.25.6.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.6.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,
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast
|
9
9
|
|
10
|
-
from .
|
11
|
-
from .
|
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
|
-
|
16
|
-
from .
|
17
|
-
from .
|
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
|
-
|
44
|
-
|
45
|
-
|
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:
|
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
|
-
|
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:
|
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:
|
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
|
136
|
-
func
|
137
|
-
|
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
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
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
|
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:
|
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
|
+
]
|