schemathesis 3.39.15__py3-none-any.whl → 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +238 -308
- 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.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
- {schemathesis-3.39.15.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 -712
- 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.15.dist-info/METADATA +0 -293
- schemathesis-3.39.15.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.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,209 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from functools import lru_cache
|
5
|
+
from typing import Any, Callable, Generator, Literal, cast
|
6
|
+
|
7
|
+
from schemathesis.core import NOT_SET, NotSet
|
8
|
+
from schemathesis.core.errors import InvalidTransition, OperationNotFound, TransitionValidationError
|
9
|
+
from schemathesis.core.result import Err, Ok, Result
|
10
|
+
from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
|
11
|
+
from schemathesis.schemas import APIOperation
|
12
|
+
from schemathesis.specs.openapi import expressions
|
13
|
+
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
14
|
+
from schemathesis.specs.openapi.references import RECURSION_DEPTH_LIMIT
|
15
|
+
|
16
|
+
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
17
|
+
ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
|
18
|
+
|
19
|
+
|
20
|
+
@dataclass
|
21
|
+
class NormalizedParameter:
|
22
|
+
"""Processed link parameter with resolved container information."""
|
23
|
+
|
24
|
+
location: ParameterLocation | None
|
25
|
+
name: str
|
26
|
+
expression: str
|
27
|
+
container_name: str
|
28
|
+
|
29
|
+
__slots__ = ("location", "name", "expression", "container_name")
|
30
|
+
|
31
|
+
|
32
|
+
@dataclass(repr=False)
|
33
|
+
class OpenApiLink:
|
34
|
+
"""Represents an OpenAPI link between operations."""
|
35
|
+
|
36
|
+
name: str
|
37
|
+
status_code: str
|
38
|
+
source: APIOperation
|
39
|
+
target: APIOperation
|
40
|
+
parameters: list[NormalizedParameter]
|
41
|
+
body: dict[str, Any] | NotSet
|
42
|
+
merge_body: bool
|
43
|
+
|
44
|
+
__slots__ = ("name", "status_code", "source", "target", "parameters", "body", "merge_body", "_cached_extract")
|
45
|
+
|
46
|
+
def __init__(self, name: str, status_code: str, definition: dict[str, Any], source: APIOperation):
|
47
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
48
|
+
|
49
|
+
self.name = name
|
50
|
+
self.status_code = status_code
|
51
|
+
self.source = source
|
52
|
+
assert isinstance(source.schema, BaseOpenAPISchema)
|
53
|
+
errors = []
|
54
|
+
|
55
|
+
get_operation: Callable[[str], APIOperation]
|
56
|
+
if "operationId" in definition:
|
57
|
+
operation_reference = definition["operationId"]
|
58
|
+
get_operation = source.schema.get_operation_by_id
|
59
|
+
else:
|
60
|
+
operation_reference = definition["operationRef"]
|
61
|
+
get_operation = source.schema.get_operation_by_reference
|
62
|
+
|
63
|
+
try:
|
64
|
+
self.target = get_operation(operation_reference)
|
65
|
+
target = self.target.label
|
66
|
+
except OperationNotFound:
|
67
|
+
target = operation_reference
|
68
|
+
errors.append(TransitionValidationError(f"Operation '{operation_reference}' not found"))
|
69
|
+
|
70
|
+
extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
71
|
+
self.parameters = self._normalize_parameters(definition.get("parameters", {}), errors)
|
72
|
+
self.body = definition.get("requestBody", NOT_SET)
|
73
|
+
self.merge_body = extension.get("merge_body", True) if extension else True
|
74
|
+
|
75
|
+
if errors:
|
76
|
+
raise InvalidTransition(
|
77
|
+
name=self.name,
|
78
|
+
source=self.source.label,
|
79
|
+
target=target,
|
80
|
+
status_code=self.status_code,
|
81
|
+
errors=errors,
|
82
|
+
)
|
83
|
+
|
84
|
+
self._cached_extract = lru_cache(8)(self._extract_impl)
|
85
|
+
|
86
|
+
def _normalize_parameters(
|
87
|
+
self, parameters: dict[str, str], errors: list[TransitionValidationError]
|
88
|
+
) -> list[NormalizedParameter]:
|
89
|
+
"""Process link parameters and resolve their container locations.
|
90
|
+
|
91
|
+
Handles both explicit locations (e.g., "path.id") and implicit ones resolved from target operation.
|
92
|
+
"""
|
93
|
+
result = []
|
94
|
+
for parameter, expression in parameters.items():
|
95
|
+
location: ParameterLocation | None
|
96
|
+
try:
|
97
|
+
# The parameter name is prefixed with its location. Example: `path.id`
|
98
|
+
_location, name = tuple(parameter.split("."))
|
99
|
+
location = cast(ParameterLocation, _location)
|
100
|
+
except ValueError:
|
101
|
+
location = None
|
102
|
+
name = parameter
|
103
|
+
|
104
|
+
if isinstance(expression, str):
|
105
|
+
try:
|
106
|
+
parsed = expressions.parser.parse(expression)
|
107
|
+
# Find NonBodyRequest nodes that reference source parameters
|
108
|
+
for node in parsed:
|
109
|
+
if isinstance(node, expressions.nodes.NonBodyRequest):
|
110
|
+
# Check if parameter exists in source operation
|
111
|
+
if not any(
|
112
|
+
p.name == node.parameter and p.location == node.location
|
113
|
+
for p in self.source.iter_parameters()
|
114
|
+
):
|
115
|
+
errors.append(
|
116
|
+
TransitionValidationError(
|
117
|
+
f"Expression `{expression}` references non-existent {node.location} parameter "
|
118
|
+
f"`{node.parameter}` in `{self.source.label}`"
|
119
|
+
)
|
120
|
+
)
|
121
|
+
except Exception as exc:
|
122
|
+
errors.append(TransitionValidationError(str(exc)))
|
123
|
+
|
124
|
+
if hasattr(self, "target"):
|
125
|
+
try:
|
126
|
+
container_name = self._get_parameter_container(location, name)
|
127
|
+
except TransitionValidationError as exc:
|
128
|
+
errors.append(exc)
|
129
|
+
continue
|
130
|
+
else:
|
131
|
+
continue
|
132
|
+
result.append(NormalizedParameter(location, name, expression, container_name))
|
133
|
+
return result
|
134
|
+
|
135
|
+
def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
|
136
|
+
"""Resolve parameter container either from explicit location or by looking up in target operation."""
|
137
|
+
if location:
|
138
|
+
return LOCATION_TO_CONTAINER[location]
|
139
|
+
|
140
|
+
for param in self.target.iter_parameters():
|
141
|
+
if param.name == name:
|
142
|
+
return LOCATION_TO_CONTAINER[param.location]
|
143
|
+
raise TransitionValidationError(f"Parameter `{name}` is not defined in API operation `{self.target.label}`")
|
144
|
+
|
145
|
+
def extract(self, output: StepOutput) -> Transition:
|
146
|
+
return self._cached_extract(StepOutputWrapper(output))
|
147
|
+
|
148
|
+
def _extract_impl(self, wrapper: StepOutputWrapper) -> Transition:
|
149
|
+
output = wrapper.output
|
150
|
+
return Transition(
|
151
|
+
id=f"{self.source.label} -> [{self.status_code}] {self.name} -> {self.target.label}",
|
152
|
+
parent_id=output.case.id,
|
153
|
+
parameters=self.extract_parameters(output),
|
154
|
+
request_body=self.extract_body(output),
|
155
|
+
)
|
156
|
+
|
157
|
+
def extract_parameters(self, output: StepOutput) -> dict[str, dict[str, ExtractedParam]]:
|
158
|
+
"""Extract parameters using runtime expressions.
|
159
|
+
|
160
|
+
Returns a two-level dictionary: container -> parameter name -> extracted value
|
161
|
+
"""
|
162
|
+
extracted: dict[str, dict[str, ExtractedParam]] = {}
|
163
|
+
for parameter in self.parameters:
|
164
|
+
container = extracted.setdefault(parameter.container_name, {})
|
165
|
+
value: Result[Any, Exception]
|
166
|
+
try:
|
167
|
+
value = Ok(expressions.evaluate(parameter.expression, output))
|
168
|
+
except Exception as exc:
|
169
|
+
value = Err(exc)
|
170
|
+
container[parameter.name] = ExtractedParam(definition=parameter.expression, value=value)
|
171
|
+
return extracted
|
172
|
+
|
173
|
+
def extract_body(self, output: StepOutput) -> ExtractedParam | None:
|
174
|
+
if not isinstance(self.body, NotSet):
|
175
|
+
value: Result[Any, Exception]
|
176
|
+
try:
|
177
|
+
value = Ok(expressions.evaluate(self.body, output, evaluate_nested=True))
|
178
|
+
except Exception as exc:
|
179
|
+
value = Err(exc)
|
180
|
+
return ExtractedParam(definition=self.body, value=value)
|
181
|
+
return None
|
182
|
+
|
183
|
+
|
184
|
+
@dataclass
|
185
|
+
class StepOutputWrapper:
|
186
|
+
"""Wrapper for StepOutput that uses only case_id for hash-based caching."""
|
187
|
+
|
188
|
+
output: StepOutput
|
189
|
+
__slots__ = ("output",)
|
190
|
+
|
191
|
+
def __hash__(self) -> int:
|
192
|
+
return hash(self.output.case.id)
|
193
|
+
|
194
|
+
def __eq__(self, other: object) -> bool:
|
195
|
+
assert isinstance(other, StepOutputWrapper)
|
196
|
+
return self.output.case.id == other.output.case.id
|
197
|
+
|
198
|
+
|
199
|
+
def get_all_links(
|
200
|
+
operation: APIOperation,
|
201
|
+
) -> Generator[tuple[str, Result[OpenApiLink, InvalidTransition]], None, None]:
|
202
|
+
for status_code, definition in operation.definition.raw["responses"].items():
|
203
|
+
definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
|
204
|
+
for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
|
205
|
+
try:
|
206
|
+
link = OpenApiLink(name, status_code, link_definition, operation)
|
207
|
+
yield status_code, Ok(link)
|
208
|
+
except InvalidTransition as exc:
|
209
|
+
yield status_code, Err(exc)
|
@@ -0,0 +1,142 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from inspect import iscoroutinefunction
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Generic, Iterator, TypeVar, Union
|
6
|
+
|
7
|
+
from schemathesis.core import media_types
|
8
|
+
from schemathesis.core.errors import SerializationNotPossible
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from schemathesis.core.transport import Response
|
12
|
+
from schemathesis.generation.case import Case
|
13
|
+
|
14
|
+
|
15
|
+
def get(app: Any) -> BaseTransport:
|
16
|
+
"""Get transport to send the data to the application."""
|
17
|
+
from schemathesis.transport.asgi import ASGI_TRANSPORT
|
18
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
19
|
+
from schemathesis.transport.wsgi import WSGI_TRANSPORT
|
20
|
+
|
21
|
+
if app is None:
|
22
|
+
return REQUESTS_TRANSPORT
|
23
|
+
if iscoroutinefunction(app) or (
|
24
|
+
hasattr(app, "__call__") and iscoroutinefunction(app.__call__) # noqa: B004
|
25
|
+
):
|
26
|
+
return ASGI_TRANSPORT
|
27
|
+
return WSGI_TRANSPORT
|
28
|
+
|
29
|
+
|
30
|
+
S = TypeVar("S", contravariant=True)
|
31
|
+
|
32
|
+
|
33
|
+
@dataclass
|
34
|
+
class SerializationContext:
|
35
|
+
"""Context object passed to serializer functions.
|
36
|
+
|
37
|
+
It provides access to the generated test case and any related metadata.
|
38
|
+
"""
|
39
|
+
|
40
|
+
case: Case
|
41
|
+
"""The generated test case."""
|
42
|
+
|
43
|
+
__slots__ = ("case",)
|
44
|
+
|
45
|
+
|
46
|
+
Serializer = Callable[[SerializationContext, Any], Any]
|
47
|
+
|
48
|
+
|
49
|
+
class BaseTransport(Generic[S]):
|
50
|
+
"""Base implementation with serializer registration."""
|
51
|
+
|
52
|
+
def __init__(self) -> None:
|
53
|
+
self._serializers: dict[str, Serializer] = {}
|
54
|
+
|
55
|
+
def serialize_case(self, case: Case, **kwargs: Any) -> dict[str, Any]:
|
56
|
+
"""Prepare the case for sending."""
|
57
|
+
raise NotImplementedError
|
58
|
+
|
59
|
+
def send(self, case: Case, *, session: S | None = None, **kwargs: Any) -> Response:
|
60
|
+
"""Send the case using this transport."""
|
61
|
+
raise NotImplementedError
|
62
|
+
|
63
|
+
def serializer(self, *media_types: str) -> Callable[[Serializer], Serializer]:
|
64
|
+
"""Register a serializer for given media types."""
|
65
|
+
|
66
|
+
def decorator(func: Serializer) -> Serializer:
|
67
|
+
for media_type in media_types:
|
68
|
+
self._serializers[media_type] = func
|
69
|
+
return func
|
70
|
+
|
71
|
+
return decorator
|
72
|
+
|
73
|
+
def unregister_serializer(self, *media_types: str) -> None:
|
74
|
+
for media_type in media_types:
|
75
|
+
self._serializers.pop(media_type, None)
|
76
|
+
|
77
|
+
def _copy_serializers_from(self, transport: BaseTransport) -> None:
|
78
|
+
self._serializers.update(transport._serializers)
|
79
|
+
|
80
|
+
def get_first_matching_media_type(self, media_type: str) -> tuple[str, Serializer] | None:
|
81
|
+
return next(self.get_matching_media_types(media_type), None)
|
82
|
+
|
83
|
+
def get_matching_media_types(self, media_type: str) -> Iterator[tuple[str, Serializer]]:
|
84
|
+
"""Get all registered media types matching the given media type."""
|
85
|
+
if media_type == "*/*":
|
86
|
+
# Shortcut to avoid comparing all values
|
87
|
+
yield from iter(self._serializers.items())
|
88
|
+
else:
|
89
|
+
main, sub = media_types.parse(media_type)
|
90
|
+
checks = [
|
91
|
+
media_types.is_json,
|
92
|
+
media_types.is_xml,
|
93
|
+
media_types.is_plain_text,
|
94
|
+
media_types.is_yaml,
|
95
|
+
]
|
96
|
+
for registered_media_type, serializer in self._serializers.items():
|
97
|
+
# Try known variations for popular media types and fallback to comparison
|
98
|
+
if any(check(media_type) and check(registered_media_type) for check in checks):
|
99
|
+
yield media_type, serializer
|
100
|
+
else:
|
101
|
+
target_main, target_sub = media_types.parse(registered_media_type)
|
102
|
+
if main in ("*", target_main) and sub in ("*", target_sub):
|
103
|
+
yield registered_media_type, serializer
|
104
|
+
|
105
|
+
def _get_serializer(self, input_media_type: str) -> Serializer:
|
106
|
+
pair = self.get_first_matching_media_type(input_media_type)
|
107
|
+
if pair is None:
|
108
|
+
# This media type is set manually. Otherwise, it should have been rejected during the data generation
|
109
|
+
raise SerializationNotPossible.for_media_type(input_media_type)
|
110
|
+
return pair[1]
|
111
|
+
|
112
|
+
|
113
|
+
_Serializer = Callable[[SerializationContext, Any], Union[bytes, None]]
|
114
|
+
|
115
|
+
|
116
|
+
def serializer(*media_types: str) -> Callable[[_Serializer], None]:
|
117
|
+
"""Register a serializer for specified media types on HTTP, ASGI, and WSGI transports.
|
118
|
+
|
119
|
+
Args:
|
120
|
+
*media_types: One or more MIME types (e.g., "application/json") this serializer handles.
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
A decorator that wraps a function taking `(ctx: SerializationContext, value: Any)` and returning `bytes` for serialized body and `None` for omitting request body.
|
124
|
+
|
125
|
+
"""
|
126
|
+
|
127
|
+
def register(func: _Serializer) -> None:
|
128
|
+
from schemathesis.transport.asgi import ASGI_TRANSPORT
|
129
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
130
|
+
from schemathesis.transport.wsgi import WSGI_TRANSPORT
|
131
|
+
|
132
|
+
@ASGI_TRANSPORT.serializer(*media_types)
|
133
|
+
@REQUESTS_TRANSPORT.serializer(*media_types)
|
134
|
+
@WSGI_TRANSPORT.serializer(*media_types)
|
135
|
+
def inner(ctx: SerializationContext, value: Any) -> dict[str, bytes]:
|
136
|
+
result = {}
|
137
|
+
serialized = func(ctx, value)
|
138
|
+
if serialized is not None:
|
139
|
+
result["data"] = serialized
|
140
|
+
return result
|
141
|
+
|
142
|
+
return register
|
@@ -0,0 +1,26 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any
|
4
|
+
|
5
|
+
from schemathesis.core.transport import Response
|
6
|
+
from schemathesis.generation.case import Case
|
7
|
+
from schemathesis.python import asgi
|
8
|
+
from schemathesis.transport.prepare import normalize_base_url
|
9
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT, RequestsTransport
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
import requests
|
13
|
+
|
14
|
+
|
15
|
+
class ASGITransport(RequestsTransport):
|
16
|
+
def send(self, case: Case, *, session: requests.Session | None = None, **kwargs: Any) -> Response:
|
17
|
+
if kwargs.get("base_url") is None:
|
18
|
+
kwargs["base_url"] = normalize_base_url(case.operation.base_url)
|
19
|
+
application = kwargs.pop("app", case.operation.app)
|
20
|
+
|
21
|
+
with asgi.get_client(application) as client:
|
22
|
+
return super().send(case, session=client, **kwargs)
|
23
|
+
|
24
|
+
|
25
|
+
ASGI_TRANSPORT = ASGITransport()
|
26
|
+
ASGI_TRANSPORT._copy_serializers_from(REQUESTS_TRANSPORT)
|
@@ -0,0 +1,124 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from functools import lru_cache
|
4
|
+
from typing import TYPE_CHECKING, Any, Mapping, cast
|
5
|
+
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
6
|
+
|
7
|
+
from schemathesis.config import SanitizationConfig
|
8
|
+
from schemathesis.core import SCHEMATHESIS_TEST_CASE_HEADER, NotSet
|
9
|
+
from schemathesis.core.errors import InvalidSchema
|
10
|
+
from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
|
11
|
+
from schemathesis.core.transport import USER_AGENT
|
12
|
+
from schemathesis.generation.meta import CoveragePhaseData
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
from requests import PreparedRequest
|
16
|
+
from requests.structures import CaseInsensitiveDict
|
17
|
+
|
18
|
+
from schemathesis.generation.case import Case
|
19
|
+
|
20
|
+
|
21
|
+
@lru_cache()
|
22
|
+
def get_default_headers() -> CaseInsensitiveDict:
|
23
|
+
from requests.utils import default_headers
|
24
|
+
|
25
|
+
headers = default_headers()
|
26
|
+
headers["User-Agent"] = USER_AGENT
|
27
|
+
return headers
|
28
|
+
|
29
|
+
|
30
|
+
def prepare_headers(case: Case, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
|
31
|
+
default_headers = get_default_headers().copy()
|
32
|
+
if case.headers:
|
33
|
+
default_headers.update(case.headers)
|
34
|
+
default_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, case.id)
|
35
|
+
if headers:
|
36
|
+
default_headers.update(headers)
|
37
|
+
for header in get_exclude_headers(case):
|
38
|
+
default_headers.pop(header, None)
|
39
|
+
return default_headers
|
40
|
+
|
41
|
+
|
42
|
+
def get_exclude_headers(case: Case) -> list[str]:
|
43
|
+
if (
|
44
|
+
case.meta is not None
|
45
|
+
and isinstance(case.meta.phase.data, CoveragePhaseData)
|
46
|
+
and case.meta.phase.data.description.startswith("Missing")
|
47
|
+
and case.meta.phase.data.description.endswith("at header")
|
48
|
+
and case.meta.phase.data.parameter is not None
|
49
|
+
):
|
50
|
+
return [case.meta.phase.data.parameter]
|
51
|
+
return []
|
52
|
+
|
53
|
+
|
54
|
+
def prepare_url(case: Case, base_url: str | None) -> str:
|
55
|
+
"""Prepare URL based on case type."""
|
56
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
57
|
+
|
58
|
+
base_url = base_url or case.operation.base_url
|
59
|
+
assert base_url is not None
|
60
|
+
path = prepare_path(case.path, case.path_parameters)
|
61
|
+
|
62
|
+
if isinstance(case.operation.schema, GraphQLSchema):
|
63
|
+
parts = list(urlsplit(base_url))
|
64
|
+
parts[2] = path
|
65
|
+
return urlunsplit(parts)
|
66
|
+
else:
|
67
|
+
path = path.lstrip("/")
|
68
|
+
if not base_url.endswith("/"):
|
69
|
+
base_url += "/"
|
70
|
+
return unquote(urljoin(base_url, quote(path)))
|
71
|
+
|
72
|
+
|
73
|
+
def prepare_body(case: Case) -> list | dict[str, Any] | str | int | float | bool | bytes | NotSet:
|
74
|
+
"""Prepare body based on case type."""
|
75
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
76
|
+
|
77
|
+
if isinstance(case.operation.schema, GraphQLSchema):
|
78
|
+
return case.body if isinstance(case.body, (NotSet, bytes)) else {"query": case.body}
|
79
|
+
return case.body
|
80
|
+
|
81
|
+
|
82
|
+
def normalize_base_url(base_url: str | None) -> str | None:
|
83
|
+
"""Normalize base URL by ensuring proper hostname for local URLs.
|
84
|
+
|
85
|
+
If URL has no hostname (typical for WSGI apps), adds "localhost" as default hostname.
|
86
|
+
"""
|
87
|
+
if base_url is None:
|
88
|
+
return None
|
89
|
+
parts = urlsplit(base_url)
|
90
|
+
if not parts.hostname:
|
91
|
+
path = cast(str, parts.path or "")
|
92
|
+
return urlunsplit(("http", "localhost", path or "", "", ""))
|
93
|
+
return base_url
|
94
|
+
|
95
|
+
|
96
|
+
def prepare_path(path: str, parameters: dict[str, Any] | None) -> str:
|
97
|
+
try:
|
98
|
+
return path.format(**parameters or {})
|
99
|
+
except KeyError as exc:
|
100
|
+
# This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
|
101
|
+
# in the parameters list.
|
102
|
+
# When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
|
103
|
+
raise InvalidSchema(f"Path parameter {exc} is not defined") from exc
|
104
|
+
except (IndexError, ValueError) as exc:
|
105
|
+
# A single unmatched `}` inside the path template may cause this
|
106
|
+
raise InvalidSchema(f"Malformed path template: `{path}`\n\n {exc}") from exc
|
107
|
+
|
108
|
+
|
109
|
+
def prepare_request(case: Case, headers: Mapping[str, Any] | None, *, config: SanitizationConfig) -> PreparedRequest:
|
110
|
+
import requests
|
111
|
+
|
112
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
113
|
+
|
114
|
+
base_url = normalize_base_url(case.operation.base_url)
|
115
|
+
kwargs = REQUESTS_TRANSPORT.serialize_case(case, base_url=base_url, headers=headers)
|
116
|
+
if config.enabled:
|
117
|
+
kwargs["url"] = sanitize_url(kwargs["url"], config=config)
|
118
|
+
sanitize_value(kwargs["headers"], config=config)
|
119
|
+
if kwargs["cookies"]:
|
120
|
+
sanitize_value(kwargs["cookies"], config=config)
|
121
|
+
if kwargs["params"]:
|
122
|
+
sanitize_value(kwargs["params"], config=config)
|
123
|
+
|
124
|
+
return requests.Request(**kwargs).prepare()
|