schemathesis 3.39.16__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.
- schemathesis/__init__.py +41 -79
- schemathesis/auths.py +111 -122
- schemathesis/checks.py +169 -60
- schemathesis/cli/__init__.py +15 -2117
- schemathesis/cli/commands/__init__.py +85 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +590 -0
- schemathesis/cli/commands/run/context.py +204 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -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 +474 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
- schemathesis/cli/commands/run/handlers/output.py +1628 -0
- schemathesis/cli/commands/run/loaders.py +114 -0
- schemathesis/cli/commands/run/validation.py +246 -0
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +84 -0
- schemathesis/cli/{options.py → ext/options.py} +36 -34
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +527 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +67 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +459 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +54 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +118 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +169 -0
- schemathesis/engine/errors.py +464 -0
- schemathesis/engine/events.py +258 -0
- schemathesis/engine/phases/__init__.py +88 -0
- schemathesis/{runner → engine/phases}/probes.py +52 -68
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +356 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +212 -0
- schemathesis/engine/phases/unit/_executor.py +416 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +247 -0
- schemathesis/errors.py +43 -0
- schemathesis/filters.py +17 -98
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +317 -0
- schemathesis/generation/coverage.py +282 -175
- schemathesis/generation/hypothesis/__init__.py +36 -0
- schemathesis/generation/hypothesis/builder.py +800 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- 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/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +116 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +278 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +284 -0
- schemathesis/hooks.py +80 -101
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +455 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +313 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +281 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -273
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +42 -6
- schemathesis/specs/graphql/schemas.py +141 -137
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +142 -156
- schemathesis/specs/openapi/checks.py +368 -257
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +23 -21
- schemathesis/specs/openapi/expressions/__init__.py +31 -19
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/lexer.py +1 -1
- schemathesis/specs/openapi/expressions/nodes.py +36 -41
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/formats.py +35 -7
- schemathesis/specs/openapi/media_types.py +53 -12
- schemathesis/specs/openapi/negative/__init__.py +7 -4
- schemathesis/specs/openapi/negative/mutations.py +6 -5
- schemathesis/specs/openapi/parameters.py +7 -10
- schemathesis/specs/openapi/patterns.py +94 -31
- schemathesis/specs/openapi/references.py +12 -53
- schemathesis/specs/openapi/schemas.py +233 -307
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +12 -6
- schemathesis/specs/openapi/stateful/__init__.py +268 -133
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/links.py +209 -0
- schemathesis/transport/__init__.py +142 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +124 -0
- schemathesis/transport/requests.py +244 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -11
- schemathesis/transport/wsgi.py +171 -0
- schemathesis-4.0.0.dist-info/METADATA +204 -0
- schemathesis-4.0.0.dist-info/RECORD +164 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -717
- schemathesis/_override.py +0 -50
- schemathesis/_patches.py +0 -21
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/callbacks.py +0 -466
- schemathesis/cli/cassettes.py +0 -561
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -920
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -54
- schemathesis/contrib/__init__.py +0 -11
- schemathesis/contrib/openapi/__init__.py +0 -11
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/experimental/__init__.py +0 -109
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -284
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -86
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -37
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/output.py +0 -68
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -88
- schemathesis/runner/impl/core.py +0 -1280
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/links.py +0 -389
- schemathesis/specs/openapi/loaders.py +0 -707
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/state_machine.py +0 -328
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -369
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.16.dist-info/METADATA +0 -293
- schemathesis-3.39.16.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.16.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,278 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import re
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from functools import lru_cache
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
7
|
+
|
8
|
+
import hypothesis
|
9
|
+
from hypothesis.errors import InvalidDefinition
|
10
|
+
from hypothesis.stateful import RuleBasedStateMachine
|
11
|
+
|
12
|
+
from schemathesis.checks import CheckFunction
|
13
|
+
from schemathesis.core import DEFAULT_STATEFUL_STEP_COUNT
|
14
|
+
from schemathesis.core.errors import NoLinksFound
|
15
|
+
from schemathesis.core.result import Result
|
16
|
+
from schemathesis.core.transport import Response
|
17
|
+
from schemathesis.generation.case import Case
|
18
|
+
|
19
|
+
if TYPE_CHECKING:
|
20
|
+
import hypothesis
|
21
|
+
from requests.structures import CaseInsensitiveDict
|
22
|
+
|
23
|
+
from schemathesis.schemas import BaseSchema
|
24
|
+
|
25
|
+
|
26
|
+
DEFAULT_STATE_MACHINE_SETTINGS = hypothesis.settings(
|
27
|
+
phases=[hypothesis.Phase.generate],
|
28
|
+
deadline=None,
|
29
|
+
stateful_step_count=DEFAULT_STATEFUL_STEP_COUNT,
|
30
|
+
suppress_health_check=list(hypothesis.HealthCheck),
|
31
|
+
)
|
32
|
+
|
33
|
+
|
34
|
+
@dataclass
|
35
|
+
class StepInput:
|
36
|
+
"""Input for a single state machine step."""
|
37
|
+
|
38
|
+
case: Case
|
39
|
+
transition: Transition | None # None for initial steps
|
40
|
+
|
41
|
+
__slots__ = ("case", "transition")
|
42
|
+
|
43
|
+
@classmethod
|
44
|
+
def initial(cls, case: Case) -> StepInput:
|
45
|
+
return cls(case=case, transition=None)
|
46
|
+
|
47
|
+
|
48
|
+
@dataclass
|
49
|
+
class Transition:
|
50
|
+
"""Data about transition execution."""
|
51
|
+
|
52
|
+
# ID of the transition (e.g. link name)
|
53
|
+
id: str
|
54
|
+
parent_id: str
|
55
|
+
parameters: dict[str, dict[str, ExtractedParam]]
|
56
|
+
request_body: ExtractedParam | None
|
57
|
+
|
58
|
+
__slots__ = ("id", "parent_id", "parameters", "request_body")
|
59
|
+
|
60
|
+
|
61
|
+
@dataclass
|
62
|
+
class ExtractedParam:
|
63
|
+
"""Result of parameter extraction."""
|
64
|
+
|
65
|
+
definition: Any
|
66
|
+
value: Result[Any, Exception]
|
67
|
+
|
68
|
+
__slots__ = ("definition", "value")
|
69
|
+
|
70
|
+
|
71
|
+
@dataclass
|
72
|
+
class ExtractionFailure:
|
73
|
+
"""Represents a failure to extract data from a transition."""
|
74
|
+
|
75
|
+
# e.g., "GetUser"
|
76
|
+
id: str
|
77
|
+
case_id: str
|
78
|
+
# e.g., "POST /users"
|
79
|
+
source: str
|
80
|
+
# e.g., "GET /users/{userId}"
|
81
|
+
target: str
|
82
|
+
# e.g., "userId"
|
83
|
+
parameter_name: str
|
84
|
+
# e.g., "$response.body#/id"
|
85
|
+
expression: str
|
86
|
+
# Previous test cases in the chain, from newest to oldest
|
87
|
+
# Stored as a case + response pair
|
88
|
+
history: list[tuple[Case, Response]]
|
89
|
+
# The actual response that caused the failure
|
90
|
+
response: Response
|
91
|
+
error: Exception | None
|
92
|
+
|
93
|
+
__slots__ = ("id", "case_id", "source", "target", "parameter_name", "expression", "history", "response", "error")
|
94
|
+
|
95
|
+
def __eq__(self, other: object) -> bool:
|
96
|
+
assert isinstance(other, ExtractionFailure)
|
97
|
+
return (
|
98
|
+
self.source == other.source
|
99
|
+
and self.target == other.target
|
100
|
+
and self.id == other.id
|
101
|
+
and self.parameter_name == other.parameter_name
|
102
|
+
and self.expression == other.expression
|
103
|
+
)
|
104
|
+
|
105
|
+
def __hash__(self) -> int:
|
106
|
+
return hash(
|
107
|
+
(
|
108
|
+
self.source,
|
109
|
+
self.target,
|
110
|
+
self.id,
|
111
|
+
self.parameter_name,
|
112
|
+
self.expression,
|
113
|
+
)
|
114
|
+
)
|
115
|
+
|
116
|
+
|
117
|
+
@dataclass
|
118
|
+
class StepOutput:
|
119
|
+
"""Output from a single transition of a state machine."""
|
120
|
+
|
121
|
+
response: Response
|
122
|
+
case: Case
|
123
|
+
|
124
|
+
__slots__ = ("response", "case")
|
125
|
+
|
126
|
+
|
127
|
+
def _normalize_name(name: str) -> str:
|
128
|
+
return re.sub(r"\W|^(?=\d)", "_", name).replace("__", "_")
|
129
|
+
|
130
|
+
|
131
|
+
class APIStateMachine(RuleBasedStateMachine):
|
132
|
+
"""State machine for executing API operation sequences based on OpenAPI links.
|
133
|
+
|
134
|
+
Automatically generates test scenarios by chaining API operations according
|
135
|
+
to their defined relationships in the schema.
|
136
|
+
"""
|
137
|
+
|
138
|
+
# This is a convenience attribute, which happened to clash with `RuleBasedStateMachine` instance level attribute
|
139
|
+
# They don't interfere, since it is properly overridden on the Hypothesis side, but it is likely that this
|
140
|
+
# attribute will be renamed in the future
|
141
|
+
bundles: ClassVar[dict[str, CaseInsensitiveDict]] # type: ignore
|
142
|
+
schema: BaseSchema
|
143
|
+
|
144
|
+
def __init__(self) -> None:
|
145
|
+
try:
|
146
|
+
super().__init__() # type: ignore
|
147
|
+
except InvalidDefinition as exc:
|
148
|
+
if "defines no rules" in str(exc):
|
149
|
+
if not self.schema.statistic.links.total:
|
150
|
+
message = "Schema contains no link definitions required for stateful testing"
|
151
|
+
else:
|
152
|
+
message = "All link definitions required for stateful testing are excluded by filters"
|
153
|
+
raise NoLinksFound(message) from None
|
154
|
+
raise
|
155
|
+
self.setup()
|
156
|
+
|
157
|
+
@classmethod
|
158
|
+
@lru_cache
|
159
|
+
def _to_test_case(cls) -> type:
|
160
|
+
from schemathesis.generation.stateful import run_state_machine_as_test
|
161
|
+
|
162
|
+
class StateMachineTestCase(RuleBasedStateMachine.TestCase):
|
163
|
+
settings = DEFAULT_STATE_MACHINE_SETTINGS
|
164
|
+
|
165
|
+
def runTest(self) -> None:
|
166
|
+
run_state_machine_as_test(cls, settings=self.settings)
|
167
|
+
|
168
|
+
runTest.is_hypothesis_test = True # type: ignore[attr-defined]
|
169
|
+
|
170
|
+
StateMachineTestCase.__name__ = cls.__name__ + ".TestCase"
|
171
|
+
StateMachineTestCase.__qualname__ = cls.__qualname__ + ".TestCase"
|
172
|
+
return StateMachineTestCase
|
173
|
+
|
174
|
+
def _new_name(self, target: str) -> str:
|
175
|
+
target = _normalize_name(target)
|
176
|
+
return super()._new_name(target) # type: ignore
|
177
|
+
|
178
|
+
def _get_target_for_result(self, result: StepOutput) -> str | None:
|
179
|
+
raise NotImplementedError
|
180
|
+
|
181
|
+
def _add_result_to_targets(self, targets: tuple[str, ...], result: StepOutput | None) -> None:
|
182
|
+
if result is None:
|
183
|
+
return
|
184
|
+
target = self._get_target_for_result(result)
|
185
|
+
if target is not None:
|
186
|
+
super()._add_result_to_targets((target,), result)
|
187
|
+
|
188
|
+
def _add_results_to_targets(self, targets: tuple[str, ...], results: list[StepOutput]) -> None:
|
189
|
+
# Hypothesis >6.131.15
|
190
|
+
for result in results:
|
191
|
+
target = self._get_target_for_result(result)
|
192
|
+
if target is not None:
|
193
|
+
super()._add_results_to_targets((target,), [result])
|
194
|
+
|
195
|
+
@classmethod
|
196
|
+
def run(cls, *, settings: hypothesis.settings | None = None) -> None:
|
197
|
+
"""Execute the state machine test scenarios.
|
198
|
+
|
199
|
+
Args:
|
200
|
+
settings: Hypothesis settings for test execution.
|
201
|
+
|
202
|
+
"""
|
203
|
+
from . import run_state_machine_as_test
|
204
|
+
|
205
|
+
__tracebackhide__ = True
|
206
|
+
return run_state_machine_as_test(cls, settings=settings)
|
207
|
+
|
208
|
+
def setup(self) -> None:
|
209
|
+
"""Called once at the beginning of each test scenario."""
|
210
|
+
|
211
|
+
def teardown(self) -> None:
|
212
|
+
"""Called once at the end of each test scenario."""
|
213
|
+
|
214
|
+
# To provide the return type in the rendered documentation
|
215
|
+
teardown.__doc__ = RuleBasedStateMachine.teardown.__doc__
|
216
|
+
|
217
|
+
def _step(self, input: StepInput) -> StepOutput | None:
|
218
|
+
__tracebackhide__ = True
|
219
|
+
return self.step(input)
|
220
|
+
|
221
|
+
def step(self, input: StepInput) -> StepOutput:
|
222
|
+
__tracebackhide__ = True
|
223
|
+
self.before_call(input.case)
|
224
|
+
kwargs = self.get_call_kwargs(input.case)
|
225
|
+
response = self.call(input.case, **kwargs)
|
226
|
+
self.after_call(response, input.case)
|
227
|
+
self.validate_response(response, input.case, **kwargs)
|
228
|
+
return StepOutput(response, input.case)
|
229
|
+
|
230
|
+
def before_call(self, case: Case) -> None:
|
231
|
+
"""Called before each API operation in the scenario.
|
232
|
+
|
233
|
+
Args:
|
234
|
+
case: Test case data for the operation.
|
235
|
+
|
236
|
+
"""
|
237
|
+
|
238
|
+
def after_call(self, response: Response, case: Case) -> None:
|
239
|
+
"""Called after each API operation in the scenario.
|
240
|
+
|
241
|
+
Args:
|
242
|
+
response: HTTP response from the operation.
|
243
|
+
case: Test case data that was executed.
|
244
|
+
|
245
|
+
"""
|
246
|
+
|
247
|
+
def call(self, case: Case, **kwargs: Any) -> Response:
|
248
|
+
return case.call(**kwargs)
|
249
|
+
|
250
|
+
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
251
|
+
"""Returns keyword arguments for the API call.
|
252
|
+
|
253
|
+
Args:
|
254
|
+
case: Test case being executed.
|
255
|
+
|
256
|
+
Returns:
|
257
|
+
Dictionary passed to the `case.call()` method.
|
258
|
+
|
259
|
+
"""
|
260
|
+
return {}
|
261
|
+
|
262
|
+
def validate_response(
|
263
|
+
self, response: Response, case: Case, additional_checks: list[CheckFunction] | None = None, **kwargs: Any
|
264
|
+
) -> None:
|
265
|
+
"""Validates the API response using configured checks.
|
266
|
+
|
267
|
+
Args:
|
268
|
+
response: HTTP response to validate.
|
269
|
+
case: Test case that generated the response.
|
270
|
+
additional_checks: Extra validation functions to run.
|
271
|
+
kwargs: Transport-level keyword arguments.
|
272
|
+
|
273
|
+
Raises:
|
274
|
+
FailureGroup: When validation checks fail.
|
275
|
+
|
276
|
+
"""
|
277
|
+
__tracebackhide__ = True
|
278
|
+
case.validate_response(response, additional_checks=additional_checks, transport_kwargs=kwargs)
|
@@ -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,109 @@
|
|
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", "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
|
+
case_id: str | None = None,
|
24
|
+
) -> None:
|
25
|
+
self.operation = operation
|
26
|
+
self.type_name = type_name
|
27
|
+
self.title = title
|
28
|
+
self.message = message
|
29
|
+
self.case_id = case_id
|
30
|
+
self.severity = Severity.MEDIUM
|
31
|
+
|
32
|
+
@property
|
33
|
+
def _unique_key(self) -> str:
|
34
|
+
return self.type_name
|
35
|
+
|
36
|
+
|
37
|
+
class GraphQLClientError(Failure):
|
38
|
+
"""GraphQL query has not been executed."""
|
39
|
+
|
40
|
+
__slots__ = ("operation", "errors", "title", "message", "case_id", "_unique_key_cache", "severity")
|
41
|
+
|
42
|
+
def __init__(
|
43
|
+
self,
|
44
|
+
*,
|
45
|
+
operation: str,
|
46
|
+
message: str,
|
47
|
+
errors: list[GraphQLFormattedError],
|
48
|
+
title: str = "GraphQL client error",
|
49
|
+
case_id: str | None = None,
|
50
|
+
) -> None:
|
51
|
+
self.operation = operation
|
52
|
+
self.errors = errors
|
53
|
+
self.title = title
|
54
|
+
self.message = message
|
55
|
+
self.case_id = case_id
|
56
|
+
self._unique_key_cache: str | None = None
|
57
|
+
self.severity = Severity.MEDIUM
|
58
|
+
|
59
|
+
@property
|
60
|
+
def _unique_key(self) -> str:
|
61
|
+
if self._unique_key_cache is None:
|
62
|
+
self._unique_key_cache = _group_graphql_errors(self.errors)
|
63
|
+
return self._unique_key_cache
|
64
|
+
|
65
|
+
|
66
|
+
class GraphQLServerError(Failure):
|
67
|
+
"""GraphQL response indicates at least one server error."""
|
68
|
+
|
69
|
+
__slots__ = ("operation", "errors", "title", "message", "case_id", "_unique_key_cache", "severity")
|
70
|
+
|
71
|
+
def __init__(
|
72
|
+
self,
|
73
|
+
*,
|
74
|
+
operation: str,
|
75
|
+
message: str,
|
76
|
+
errors: list[GraphQLFormattedError],
|
77
|
+
title: str = "GraphQL server error",
|
78
|
+
case_id: str | None = None,
|
79
|
+
) -> None:
|
80
|
+
self.operation = operation
|
81
|
+
self.errors = errors
|
82
|
+
self.title = title
|
83
|
+
self.message = message
|
84
|
+
self.case_id = case_id
|
85
|
+
self._unique_key_cache: str | None = None
|
86
|
+
self.severity = Severity.CRITICAL
|
87
|
+
|
88
|
+
@property
|
89
|
+
def _unique_key(self) -> str:
|
90
|
+
if self._unique_key_cache is None:
|
91
|
+
self._unique_key_cache = _group_graphql_errors(self.errors)
|
92
|
+
return self._unique_key_cache
|
93
|
+
|
94
|
+
|
95
|
+
def _group_graphql_errors(errors: list[GraphQLFormattedError]) -> str:
|
96
|
+
entries = []
|
97
|
+
for error in errors:
|
98
|
+
message = error["message"]
|
99
|
+
if "locations" in error:
|
100
|
+
message += ";locations:"
|
101
|
+
for location in sorted(error["locations"]):
|
102
|
+
message += f"({location['line'], location['column']})"
|
103
|
+
if "path" in error:
|
104
|
+
message += ";path:"
|
105
|
+
for chunk in error["path"]:
|
106
|
+
message += str(chunk)
|
107
|
+
entries.append(message)
|
108
|
+
entries.sort()
|
109
|
+
return "".join(entries)
|
@@ -0,0 +1,284 @@
|
|
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.config import SchemathesisConfig
|
10
|
+
from schemathesis.core.errors import LoaderError, LoaderErrorKind
|
11
|
+
from schemathesis.core.loaders import load_from_url, prepare_request_kwargs, raise_for_status, require_relative_url
|
12
|
+
from schemathesis.hooks import HookContext, dispatch
|
13
|
+
from schemathesis.python import asgi, wsgi
|
14
|
+
|
15
|
+
if TYPE_CHECKING:
|
16
|
+
from graphql import DocumentNode
|
17
|
+
|
18
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
19
|
+
|
20
|
+
|
21
|
+
def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
|
22
|
+
"""Load GraphQL schema from an ASGI application via introspection.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
path: Relative URL path to the GraphQL endpoint (e.g., "/graphql")
|
26
|
+
app: ASGI application instance
|
27
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
28
|
+
**kwargs: Additional request parameters passed to the ASGI test client.
|
29
|
+
|
30
|
+
Example:
|
31
|
+
```python
|
32
|
+
from fastapi import FastAPI
|
33
|
+
import schemathesis
|
34
|
+
|
35
|
+
app = FastAPI()
|
36
|
+
schema = schemathesis.graphql.from_asgi("/graphql", app)
|
37
|
+
```
|
38
|
+
|
39
|
+
"""
|
40
|
+
require_relative_url(path)
|
41
|
+
kwargs.setdefault("json", {"query": get_introspection_query()})
|
42
|
+
client = asgi.get_client(app)
|
43
|
+
response = load_from_url(client.post, url=path, **kwargs)
|
44
|
+
schema = extract_schema_from_response(response, lambda r: r.json())
|
45
|
+
loaded = from_dict(schema=schema, config=config)
|
46
|
+
loaded.app = app
|
47
|
+
loaded.location = path
|
48
|
+
return loaded
|
49
|
+
|
50
|
+
|
51
|
+
def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
|
52
|
+
"""Load GraphQL schema from a WSGI application via introspection.
|
53
|
+
|
54
|
+
Args:
|
55
|
+
path: Relative URL path to the GraphQL endpoint (e.g., "/graphql")
|
56
|
+
app: WSGI application instance
|
57
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
58
|
+
**kwargs: Additional request parameters passed to the WSGI test client.
|
59
|
+
|
60
|
+
Example:
|
61
|
+
```python
|
62
|
+
from flask import Flask
|
63
|
+
import schemathesis
|
64
|
+
|
65
|
+
app = Flask(__name__)
|
66
|
+
schema = schemathesis.graphql.from_wsgi("/graphql", app)
|
67
|
+
```
|
68
|
+
|
69
|
+
"""
|
70
|
+
require_relative_url(path)
|
71
|
+
prepare_request_kwargs(kwargs)
|
72
|
+
kwargs.setdefault("json", {"query": get_introspection_query()})
|
73
|
+
client = wsgi.get_client(app)
|
74
|
+
response = client.post(path=path, **kwargs)
|
75
|
+
raise_for_status(response)
|
76
|
+
schema = extract_schema_from_response(response, lambda r: r.json)
|
77
|
+
loaded = from_dict(schema=schema, config=config)
|
78
|
+
loaded.app = app
|
79
|
+
loaded.location = path
|
80
|
+
return loaded
|
81
|
+
|
82
|
+
|
83
|
+
def from_url(
|
84
|
+
url: str, *, config: SchemathesisConfig | None = None, wait_for_schema: float | None = None, **kwargs: Any
|
85
|
+
) -> GraphQLSchema:
|
86
|
+
"""Load GraphQL schema from a URL via introspection query.
|
87
|
+
|
88
|
+
Args:
|
89
|
+
url: Full URL to the GraphQL endpoint
|
90
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
91
|
+
wait_for_schema: Maximum time in seconds to wait for schema availability
|
92
|
+
**kwargs: Additional parameters passed to `requests.post()` (headers, timeout, auth, etc.).
|
93
|
+
|
94
|
+
Example:
|
95
|
+
```python
|
96
|
+
import schemathesis
|
97
|
+
|
98
|
+
# Basic usage
|
99
|
+
schema = schemathesis.graphql.from_url("https://api.example.com/graphql")
|
100
|
+
|
101
|
+
# With authentication and timeout
|
102
|
+
schema = schemathesis.graphql.from_url(
|
103
|
+
"https://api.example.com/graphql",
|
104
|
+
headers={"Authorization": "Bearer token"},
|
105
|
+
timeout=30,
|
106
|
+
wait_for_schema=10.0
|
107
|
+
)
|
108
|
+
```
|
109
|
+
|
110
|
+
"""
|
111
|
+
import requests
|
112
|
+
|
113
|
+
kwargs.setdefault("json", {"query": get_introspection_query()})
|
114
|
+
response = load_from_url(requests.post, url=url, wait_for_schema=wait_for_schema, **kwargs)
|
115
|
+
schema = extract_schema_from_response(response, lambda r: r.json())
|
116
|
+
loaded = from_dict(schema, config=config)
|
117
|
+
loaded.location = url
|
118
|
+
return loaded
|
119
|
+
|
120
|
+
|
121
|
+
def from_path(
|
122
|
+
path: PathLike | str, *, config: SchemathesisConfig | None = None, encoding: str = "utf-8"
|
123
|
+
) -> GraphQLSchema:
|
124
|
+
"""Load GraphQL schema from a filesystem path.
|
125
|
+
|
126
|
+
Args:
|
127
|
+
path: File path to the GraphQL schema file (.graphql, .gql)
|
128
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
129
|
+
encoding: Text encoding for reading the file
|
130
|
+
|
131
|
+
Example:
|
132
|
+
```python
|
133
|
+
import schemathesis
|
134
|
+
|
135
|
+
# Load from GraphQL SDL file
|
136
|
+
schema = schemathesis.graphql.from_path("./schema.graphql")
|
137
|
+
```
|
138
|
+
|
139
|
+
"""
|
140
|
+
with open(path, encoding=encoding) as file:
|
141
|
+
loaded = from_file(file=file, config=config)
|
142
|
+
loaded.location = Path(path).absolute().as_uri()
|
143
|
+
return loaded
|
144
|
+
|
145
|
+
|
146
|
+
def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
|
147
|
+
"""Load GraphQL schema from a file-like object or string.
|
148
|
+
|
149
|
+
Args:
|
150
|
+
file: File-like object or raw string containing GraphQL SDL
|
151
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
152
|
+
|
153
|
+
Example:
|
154
|
+
```python
|
155
|
+
import schemathesis
|
156
|
+
|
157
|
+
# From GraphQL SDL string
|
158
|
+
schema_sdl = '''
|
159
|
+
type Query {
|
160
|
+
user(id: ID!): User
|
161
|
+
}
|
162
|
+
type User {
|
163
|
+
id: ID!
|
164
|
+
name: String!
|
165
|
+
}
|
166
|
+
'''
|
167
|
+
schema = schemathesis.graphql.from_file(schema_sdl)
|
168
|
+
|
169
|
+
# From file object
|
170
|
+
with open("schema.graphql") as f:
|
171
|
+
schema = schemathesis.graphql.from_file(f)
|
172
|
+
```
|
173
|
+
|
174
|
+
"""
|
175
|
+
import graphql
|
176
|
+
|
177
|
+
if isinstance(file, str):
|
178
|
+
data = file
|
179
|
+
else:
|
180
|
+
data = file.read()
|
181
|
+
try:
|
182
|
+
document = graphql.build_schema(data)
|
183
|
+
result = graphql.execute(document, get_introspection_query_ast())
|
184
|
+
# TYPES: We don't pass `is_awaitable` above, therefore `result` is of the `ExecutionResult` type
|
185
|
+
result = cast(graphql.ExecutionResult, result)
|
186
|
+
# TYPES:
|
187
|
+
# - `document` is a valid schema, because otherwise `build_schema` will rise an error;
|
188
|
+
# - `INTROSPECTION_QUERY` is a valid query - it is known upfront;
|
189
|
+
# Therefore the execution result is always valid at this point and `result.data` is not `None`
|
190
|
+
schema = cast(Dict[str, Any], result.data)
|
191
|
+
except Exception as exc:
|
192
|
+
try:
|
193
|
+
schema = json.loads(data)
|
194
|
+
if not isinstance(schema, dict) or "__schema" not in schema:
|
195
|
+
_on_invalid_schema(exc)
|
196
|
+
except json.JSONDecodeError:
|
197
|
+
_on_invalid_schema(exc, extras=[entry for entry in str(exc).splitlines() if entry])
|
198
|
+
return from_dict(schema, config=config)
|
199
|
+
|
200
|
+
|
201
|
+
def from_dict(schema: dict[str, Any], *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
|
202
|
+
"""Load GraphQL schema from a dictionary containing introspection result.
|
203
|
+
|
204
|
+
Args:
|
205
|
+
schema: Dictionary containing GraphQL introspection result or wrapped in 'data' key
|
206
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
207
|
+
|
208
|
+
Example:
|
209
|
+
```python
|
210
|
+
import schemathesis
|
211
|
+
|
212
|
+
# From introspection result
|
213
|
+
introspection = {
|
214
|
+
"__schema": {
|
215
|
+
"types": [...],
|
216
|
+
"queryType": {"name": "Query"},
|
217
|
+
# ... rest of introspection result
|
218
|
+
}
|
219
|
+
}
|
220
|
+
schema = schemathesis.graphql.from_dict(introspection)
|
221
|
+
|
222
|
+
# From GraphQL response format (with 'data' wrapper)
|
223
|
+
response_data = {
|
224
|
+
"data": {
|
225
|
+
"__schema": {
|
226
|
+
"types": [...],
|
227
|
+
"queryType": {"name": "Query"}
|
228
|
+
}
|
229
|
+
}
|
230
|
+
}
|
231
|
+
schema = schemathesis.graphql.from_dict(response_data)
|
232
|
+
```
|
233
|
+
|
234
|
+
"""
|
235
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
236
|
+
|
237
|
+
if "data" in schema:
|
238
|
+
schema = schema["data"]
|
239
|
+
hook_context = HookContext()
|
240
|
+
dispatch("before_load_schema", hook_context, schema)
|
241
|
+
|
242
|
+
if config is None:
|
243
|
+
config = SchemathesisConfig.discover()
|
244
|
+
project_config = config.projects.get(schema)
|
245
|
+
instance = GraphQLSchema(schema, config=project_config)
|
246
|
+
dispatch("after_load_schema", hook_context, instance)
|
247
|
+
return instance
|
248
|
+
|
249
|
+
|
250
|
+
@lru_cache
|
251
|
+
def get_introspection_query() -> str:
|
252
|
+
import graphql
|
253
|
+
|
254
|
+
return graphql.get_introspection_query()
|
255
|
+
|
256
|
+
|
257
|
+
@lru_cache
|
258
|
+
def get_introspection_query_ast() -> DocumentNode:
|
259
|
+
import graphql
|
260
|
+
|
261
|
+
query = get_introspection_query()
|
262
|
+
return graphql.parse(query)
|
263
|
+
|
264
|
+
|
265
|
+
R = TypeVar("R")
|
266
|
+
|
267
|
+
|
268
|
+
def extract_schema_from_response(response: R, callback: Callable[[R], Any]) -> dict[str, Any]:
|
269
|
+
try:
|
270
|
+
decoded = callback(response)
|
271
|
+
except json.JSONDecodeError as exc:
|
272
|
+
raise LoaderError(
|
273
|
+
LoaderErrorKind.UNEXPECTED_CONTENT_TYPE,
|
274
|
+
"Received unsupported content while expecting a JSON payload for GraphQL",
|
275
|
+
) from exc
|
276
|
+
return decoded
|
277
|
+
|
278
|
+
|
279
|
+
def _on_invalid_schema(exc: Exception, extras: list[str] | None = None) -> NoReturn:
|
280
|
+
raise LoaderError(
|
281
|
+
LoaderErrorKind.GRAPHQL_INVALID_SCHEMA,
|
282
|
+
"The provided API schema does not appear to be a valid GraphQL schema",
|
283
|
+
extras=extras or [],
|
284
|
+
) from exc
|