schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__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 +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- 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 +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -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} +59 -175
- schemathesis/cli/constants.py +5 -58
- 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} +37 -16
- 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 -7
- 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 +315 -0
- schemathesis/core/fs.py +19 -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/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -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 +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 +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -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/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -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} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- 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 +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- 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} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- 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 -936
- 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 -56
- 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/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- 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 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- 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 -104
- schemathesis/runner/impl/core.py +0 -1246
- 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/loaders.py +0 -708
- 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/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- 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.7.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.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -1,334 +1,158 @@
|
|
1
|
-
"""Open API links support.
|
2
|
-
|
3
|
-
Based on https://swagger.io/docs/specification/links/
|
4
|
-
"""
|
5
|
-
|
6
1
|
from __future__ import annotations
|
7
2
|
|
8
|
-
from dataclasses import dataclass
|
9
|
-
from
|
10
|
-
from
|
11
|
-
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from functools import lru_cache
|
5
|
+
from typing import TYPE_CHECKING, Any, Generator, Literal, Union, cast
|
6
|
+
|
7
|
+
from schemathesis.core import NOT_SET, NotSet
|
8
|
+
from schemathesis.core.result import Err, Ok, Result
|
9
|
+
from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
|
10
|
+
from schemathesis.schemas import APIOperation
|
12
11
|
|
13
|
-
from ...constants import NOT_SET
|
14
|
-
from ...internal.copy import fast_deepcopy
|
15
|
-
from ...models import APIOperation, Case, TransitionId
|
16
|
-
from ...stateful import ParsedData, StatefulTest, UnresolvableLink
|
17
|
-
from ...stateful.state_machine import Direction
|
18
12
|
from . import expressions
|
19
13
|
from .constants import LOCATION_TO_CONTAINER
|
20
|
-
from .
|
21
|
-
from .references import RECURSION_DEPTH_LIMIT, Unresolvable
|
14
|
+
from .references import RECURSION_DEPTH_LIMIT
|
22
15
|
|
23
16
|
if TYPE_CHECKING:
|
24
|
-
from hypothesis.vendor.pretty import RepresentationPrinter
|
25
17
|
from jsonschema import RefResolver
|
26
18
|
|
27
|
-
from ...parameters import ParameterSet
|
28
|
-
from ...transports.responses import GenericResponse
|
29
|
-
from ...types import NotSet
|
30
19
|
|
20
|
+
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
21
|
+
ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
|
31
22
|
|
32
|
-
@dataclass(repr=False)
|
33
|
-
class Link(StatefulTest):
|
34
|
-
operation: APIOperation
|
35
|
-
parameters: dict[str, Any]
|
36
|
-
request_body: Any = NOT_SET
|
37
|
-
merge_body: bool = True
|
38
|
-
|
39
|
-
def __post_init__(self) -> None:
|
40
|
-
if self.request_body is not NOT_SET and not self.operation.body:
|
41
|
-
# Link defines `requestBody` for a parameter that does not accept one
|
42
|
-
raise ValueError(
|
43
|
-
f"Request body is not defined in API operation {self.operation.method.upper()} {self.operation.path}"
|
44
|
-
)
|
45
|
-
|
46
|
-
@classmethod
|
47
|
-
def from_definition(cls, name: str, definition: dict[str, dict[str, Any]], source_operation: APIOperation) -> Link:
|
48
|
-
# Links can be behind a reference
|
49
|
-
_, definition = source_operation.schema.resolver.resolve_in_scope( # type: ignore
|
50
|
-
definition, source_operation.definition.scope
|
51
|
-
)
|
52
|
-
if "operationId" in definition:
|
53
|
-
# source_operation.schema is `BaseOpenAPISchema` and has this method
|
54
|
-
operation = source_operation.schema.get_operation_by_id(definition["operationId"]) # type: ignore
|
55
|
-
else:
|
56
|
-
operation = source_operation.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
|
57
|
-
extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
58
|
-
return cls(
|
59
|
-
# Pylint can't detect that the API operation is always defined at this point
|
60
|
-
# E.g. if there is no matching operation or no operations at all, then a ValueError will be risen
|
61
|
-
name=name,
|
62
|
-
operation=operation,
|
63
|
-
parameters=definition.get("parameters", {}),
|
64
|
-
request_body=definition.get("requestBody", NOT_SET), # `None` might be a valid value - `null`
|
65
|
-
merge_body=extension.get("merge_body", True) if extension is not None else True,
|
66
|
-
)
|
67
23
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
parameters = {}
|
72
|
-
for parameter, expression in self.parameters.items():
|
73
|
-
evaluated = expressions.evaluate(expression, context)
|
74
|
-
if isinstance(evaluated, Unresolvable):
|
75
|
-
raise UnresolvableLink(f"Unresolvable reference in the link: {expression}")
|
76
|
-
parameters[parameter] = evaluated
|
77
|
-
body = expressions.evaluate(self.request_body, context, evaluate_nested=True)
|
78
|
-
if self.merge_body:
|
79
|
-
body = merge_body(case.body, body)
|
80
|
-
return ParsedData(parameters=parameters, body=body)
|
81
|
-
|
82
|
-
def is_match(self) -> bool:
|
83
|
-
return self.operation.schema.filter_set.match(SimpleNamespace(operation=self.operation))
|
84
|
-
|
85
|
-
def make_operation(self, collected: list[ParsedData]) -> APIOperation:
|
86
|
-
"""Create a modified version of the original API operation with additional data merged in."""
|
87
|
-
# We split the gathered data among all locations & store the original parameter
|
88
|
-
containers = {
|
89
|
-
location: {
|
90
|
-
parameter.name: {"options": [], "parameter": parameter}
|
91
|
-
for parameter in getattr(self.operation, container_name)
|
92
|
-
}
|
93
|
-
for location, container_name in LOCATION_TO_CONTAINER.items()
|
94
|
-
}
|
95
|
-
# There might be duplicates in the data
|
96
|
-
for item in set(collected):
|
97
|
-
for name, value in item.parameters.items():
|
98
|
-
container = self._get_container_by_parameter_name(name, containers)
|
99
|
-
container.append(value)
|
100
|
-
if "body" in containers["body"] and item.body is not NOT_SET:
|
101
|
-
containers["body"]["body"]["options"].append(item.body)
|
102
|
-
# These are the final `path_parameters`, `query`, and other API operation components
|
103
|
-
components: dict[str, ParameterSet] = {
|
104
|
-
container_name: getattr(self.operation, container_name).__class__()
|
105
|
-
for location, container_name in LOCATION_TO_CONTAINER.items()
|
106
|
-
}
|
107
|
-
# Here are all components that are filled with parameters
|
108
|
-
for location, parameters in containers.items():
|
109
|
-
for parameter_data in parameters.values():
|
110
|
-
parameter = parameter_data["parameter"]
|
111
|
-
if parameter_data["options"]:
|
112
|
-
definition = fast_deepcopy(parameter.definition)
|
113
|
-
if "schema" in definition:
|
114
|
-
# The actual schema doesn't matter since we have a list of allowed values
|
115
|
-
definition["schema"] = {"enum": parameter_data["options"]}
|
116
|
-
else:
|
117
|
-
# Other schema-related keywords will be ignored later, during the canonicalisation step
|
118
|
-
# inside `hypothesis-jsonschema`
|
119
|
-
definition["enum"] = parameter_data["options"]
|
120
|
-
new_parameter: OpenAPIParameter
|
121
|
-
if isinstance(parameter, OpenAPI30Body):
|
122
|
-
new_parameter = parameter.__class__(
|
123
|
-
definition, media_type=parameter.media_type, required=parameter.required
|
124
|
-
)
|
125
|
-
elif isinstance(parameter, OpenAPI20Body):
|
126
|
-
new_parameter = parameter.__class__(definition, media_type=parameter.media_type)
|
127
|
-
else:
|
128
|
-
new_parameter = parameter.__class__(definition)
|
129
|
-
components[LOCATION_TO_CONTAINER[location]].add(new_parameter)
|
130
|
-
else:
|
131
|
-
# No options were gathered for this parameter - use the original one
|
132
|
-
components[LOCATION_TO_CONTAINER[location]].add(parameter)
|
133
|
-
return self.operation.clone(**components)
|
134
|
-
|
135
|
-
def _get_container_by_parameter_name(self, full_name: str, templates: dict[str, dict[str, dict[str, Any]]]) -> list:
|
136
|
-
"""Detect in what request part the parameters is defined."""
|
137
|
-
location: str | None
|
138
|
-
try:
|
139
|
-
# The parameter name is prefixed with its location. Example: `path.id`
|
140
|
-
location, name = full_name.split(".")
|
141
|
-
except ValueError:
|
142
|
-
location, name = None, full_name
|
143
|
-
if location:
|
144
|
-
try:
|
145
|
-
parameters = templates[location]
|
146
|
-
except KeyError:
|
147
|
-
self._unknown_parameter(full_name)
|
148
|
-
else:
|
149
|
-
for parameters in templates.values():
|
150
|
-
if name in parameters:
|
151
|
-
break
|
152
|
-
else:
|
153
|
-
self._unknown_parameter(full_name)
|
154
|
-
if not parameters:
|
155
|
-
self._unknown_parameter(full_name)
|
156
|
-
return parameters[name]["options"]
|
157
|
-
|
158
|
-
def _unknown_parameter(self, name: str) -> NoReturn:
|
159
|
-
raise ValueError(
|
160
|
-
f"Parameter `{name}` is not defined in API operation {self.operation.method.upper()} {self.operation.path}"
|
161
|
-
)
|
24
|
+
@dataclass
|
25
|
+
class NormalizedParameter:
|
26
|
+
"""Processed link parameter with resolved container information."""
|
162
27
|
|
28
|
+
location: ParameterLocation | None
|
29
|
+
name: str
|
30
|
+
expression: str
|
31
|
+
container_name: str
|
163
32
|
|
164
|
-
|
165
|
-
"""Get `x-links` / `links` definitions from the schema."""
|
166
|
-
responses = operation.definition.raw["responses"]
|
167
|
-
if str(response.status_code) in responses:
|
168
|
-
definition = responses[str(response.status_code)]
|
169
|
-
elif response.status_code in responses:
|
170
|
-
definition = responses[response.status_code]
|
171
|
-
else:
|
172
|
-
definition = responses.get("default", {})
|
173
|
-
if not definition:
|
174
|
-
return []
|
175
|
-
_, definition = operation.schema.resolver.resolve_in_scope(definition, operation.definition.scope) # type: ignore[attr-defined]
|
176
|
-
links = definition.get(field, {})
|
177
|
-
return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
|
178
|
-
|
33
|
+
__slots__ = ("location", "name", "expression", "container_name")
|
179
34
|
|
180
|
-
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
181
35
|
|
36
|
+
@dataclass(repr=False)
|
37
|
+
class OpenApiLink:
|
38
|
+
"""Represents an OpenAPI link between operations."""
|
182
39
|
|
183
|
-
|
40
|
+
name: str
|
41
|
+
status_code: str
|
42
|
+
source: APIOperation
|
43
|
+
target: APIOperation
|
44
|
+
parameters: list[NormalizedParameter]
|
45
|
+
body: dict[str, Any] | NotSet
|
184
46
|
merge_body: bool
|
185
47
|
|
48
|
+
__slots__ = ("name", "status_code", "source", "target", "parameters", "body", "merge_body", "_cached_extract")
|
186
49
|
|
187
|
-
|
188
|
-
|
189
|
-
|
50
|
+
def __init__(self, name: str, status_code: str, definition: dict[str, Any], source: APIOperation):
|
51
|
+
self.name = name
|
52
|
+
self.status_code = status_code
|
53
|
+
self.source = source
|
190
54
|
|
191
|
-
|
192
|
-
|
55
|
+
if "operationId" in definition:
|
56
|
+
self.target = source.schema.get_operation_by_id(definition["operationId"]) # type: ignore
|
57
|
+
else:
|
58
|
+
self.target = source.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
|
193
59
|
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
parameters: list[tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]] = field(init=False)
|
199
|
-
body: dict[str, Any] | NotSet = field(init=False)
|
200
|
-
merge_body: bool = True
|
201
|
-
|
202
|
-
def __repr__(self) -> str:
|
203
|
-
path = self.operation.path
|
204
|
-
method = self.operation.method
|
205
|
-
return f"state.schema['{path}']['{method}'].links['{self.status_code}']['{self.name}']"
|
206
|
-
|
207
|
-
def _repr_pretty_(self, printer: RepresentationPrinter, cycle: bool) -> None:
|
208
|
-
return printer.text(repr(self))
|
209
|
-
|
210
|
-
def __post_init__(self) -> None:
|
211
|
-
extension = self.definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
212
|
-
self.parameters = [
|
213
|
-
normalize_parameter(parameter, expression)
|
214
|
-
for parameter, expression in self.definition.get("parameters", {}).items()
|
215
|
-
]
|
216
|
-
self.body = self.definition.get("requestBody", NOT_SET)
|
217
|
-
if extension is not None:
|
218
|
-
self.merge_body = extension.get("merge_body", True)
|
219
|
-
|
220
|
-
def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
|
221
|
-
"""Assign all linked definitions to the new case instance."""
|
222
|
-
context = kwargs["context"]
|
223
|
-
overrides = self.set_parameters(case, context)
|
224
|
-
self.set_body(case, context, overrides)
|
225
|
-
overrides_all_parameters = True
|
226
|
-
if case.operation.body and "body" not in overrides.get("body", []):
|
227
|
-
overrides_all_parameters = False
|
228
|
-
if overrides_all_parameters:
|
229
|
-
for parameter in case.operation.iter_parameters():
|
230
|
-
if parameter.name not in overrides.get(parameter.location, []):
|
231
|
-
overrides_all_parameters = False
|
232
|
-
break
|
233
|
-
case.set_source(
|
234
|
-
context.response,
|
235
|
-
context.case,
|
236
|
-
elapsed,
|
237
|
-
overrides_all_parameters,
|
238
|
-
transition_id=TransitionId(
|
239
|
-
name=self.name,
|
240
|
-
status_code=self.status_code,
|
241
|
-
),
|
242
|
-
)
|
60
|
+
extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
61
|
+
self.parameters = self._normalize_parameters(definition.get("parameters", {}))
|
62
|
+
self.body = definition.get("requestBody", NOT_SET)
|
63
|
+
self.merge_body = extension.get("merge_body", True) if extension else True
|
243
64
|
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
return
|
265
|
-
|
266
|
-
def
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
if self.body is not NOT_SET:
|
273
|
-
evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
|
274
|
-
overrides["body"] = ["body"]
|
275
|
-
if self.merge_body:
|
276
|
-
case.body = merge_body(case.body, evaluated)
|
277
|
-
else:
|
278
|
-
case.body = evaluated
|
279
|
-
|
280
|
-
def get_target_operation(self) -> APIOperation:
|
281
|
-
if "operationId" in self.definition:
|
282
|
-
return self.operation.schema.get_operation_by_id(self.definition["operationId"]) # type: ignore
|
283
|
-
return self.operation.schema.get_operation_by_reference(self.definition["operationRef"]) # type: ignore
|
284
|
-
|
285
|
-
|
286
|
-
def merge_body(old: Any, new: Any) -> Any:
|
287
|
-
if isinstance(old, dict) and isinstance(new, dict):
|
288
|
-
return {**old, **new}
|
289
|
-
return new
|
290
|
-
|
291
|
-
|
292
|
-
def get_container(
|
293
|
-
case: Case, location: Literal["path", "query", "header", "cookie", "body"] | None, name: str
|
294
|
-
) -> tuple[Literal["path", "query", "header", "cookie", "body"], dict[str, Any] | None]:
|
295
|
-
"""Get a container that suppose to store the given parameter."""
|
296
|
-
if location:
|
297
|
-
container_name = LOCATION_TO_CONTAINER[location]
|
298
|
-
else:
|
299
|
-
for param in case.operation.iter_parameters():
|
65
|
+
self._cached_extract = lru_cache(8)(self._extract_impl)
|
66
|
+
|
67
|
+
def _normalize_parameters(self, parameters: dict[str, str]) -> list[NormalizedParameter]:
|
68
|
+
"""Process link parameters and resolve their container locations.
|
69
|
+
|
70
|
+
Handles both explicit locations (e.g., "path.id") and implicit ones resolved from target operation.
|
71
|
+
"""
|
72
|
+
result = []
|
73
|
+
for parameter, expression in parameters.items():
|
74
|
+
location: ParameterLocation | None
|
75
|
+
try:
|
76
|
+
# The parameter name is prefixed with its location. Example: `path.id`
|
77
|
+
_location, name = tuple(parameter.split("."))
|
78
|
+
location = cast(ParameterLocation, _location)
|
79
|
+
except ValueError:
|
80
|
+
location = None
|
81
|
+
name = parameter
|
82
|
+
|
83
|
+
container_name = self._get_parameter_container(location, name)
|
84
|
+
result.append(NormalizedParameter(location, name, expression, container_name))
|
85
|
+
return result
|
86
|
+
|
87
|
+
def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
|
88
|
+
"""Resolve parameter container either from explicit location or by looking up in target operation."""
|
89
|
+
if location:
|
90
|
+
return LOCATION_TO_CONTAINER[location]
|
91
|
+
|
92
|
+
for param in self.target.iter_parameters():
|
300
93
|
if param.name == name:
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
94
|
+
return LOCATION_TO_CONTAINER[param.location]
|
95
|
+
raise ValueError(f"Parameter `{name}` is not defined in API operation `{self.target.label}`")
|
96
|
+
|
97
|
+
def extract(self, output: StepOutput) -> Transition:
|
98
|
+
return self._cached_extract(StepOutputWrapper(output))
|
99
|
+
|
100
|
+
def _extract_impl(self, wrapper: StepOutputWrapper) -> Transition:
|
101
|
+
output = wrapper.output
|
102
|
+
return Transition(
|
103
|
+
id=f"{self.source.label} - {self.status_code} - {self.name}",
|
104
|
+
parent_id=output.case.id,
|
105
|
+
parameters=self.extract_parameters(output),
|
106
|
+
request_body=self.extract_body(output),
|
107
|
+
)
|
108
|
+
|
109
|
+
def extract_parameters(self, output: StepOutput) -> dict[str, dict[str, ExtractedParam]]:
|
110
|
+
"""Extract parameters using runtime expressions.
|
111
|
+
|
112
|
+
Returns a two-level dictionary: container -> parameter name -> extracted value
|
113
|
+
"""
|
114
|
+
extracted: dict[str, dict[str, ExtractedParam]] = {}
|
115
|
+
for parameter in self.parameters:
|
116
|
+
container = extracted.setdefault(parameter.container_name, {})
|
117
|
+
value: Result[Any, Exception]
|
118
|
+
try:
|
119
|
+
value = Ok(expressions.evaluate(parameter.expression, output))
|
120
|
+
except Exception as exc:
|
121
|
+
value = Err(exc)
|
122
|
+
container[parameter.name] = ExtractedParam(definition=parameter.expression, value=value)
|
123
|
+
return extracted
|
124
|
+
|
125
|
+
def extract_body(self, output: StepOutput) -> ExtractedParam | None:
|
126
|
+
if not isinstance(self.body, NotSet):
|
127
|
+
value: Result[Any, Exception]
|
128
|
+
try:
|
129
|
+
value = Ok(expressions.evaluate(self.body, output, evaluate_nested=True))
|
130
|
+
except Exception as exc:
|
131
|
+
value = Err(exc)
|
132
|
+
return ExtractedParam(definition=self.body, value=value)
|
133
|
+
return None
|
134
|
+
|
135
|
+
|
136
|
+
@dataclass
|
137
|
+
class StepOutputWrapper:
|
138
|
+
"""Wrapper for StepOutput that uses only case_id for hash-based caching."""
|
307
139
|
|
140
|
+
output: StepOutput
|
141
|
+
__slots__ = ("output",)
|
308
142
|
|
309
|
-
def
|
310
|
-
|
311
|
-
) -> tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]:
|
312
|
-
"""Normalize runtime expressions.
|
143
|
+
def __hash__(self) -> int:
|
144
|
+
return hash(self.output.case.id)
|
313
145
|
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
"""
|
318
|
-
try:
|
319
|
-
# The parameter name is prefixed with its location. Example: `path.id`
|
320
|
-
location, name = tuple(parameter.split("."))
|
321
|
-
_location = cast(Literal["path", "query", "header", "cookie", "body"], location)
|
322
|
-
return _location, name, expression
|
323
|
-
except ValueError:
|
324
|
-
return None, parameter, expression
|
146
|
+
def __eq__(self, other: object) -> bool:
|
147
|
+
assert isinstance(other, StepOutputWrapper)
|
148
|
+
return self.output.case.id == other.output.case.id
|
325
149
|
|
326
150
|
|
327
|
-
def get_all_links(operation: APIOperation) -> Generator[tuple[str,
|
151
|
+
def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenApiLink], None, None]:
|
328
152
|
for status_code, definition in operation.definition.raw["responses"].items():
|
329
153
|
definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
|
330
154
|
for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
|
331
|
-
yield status_code,
|
155
|
+
yield status_code, OpenApiLink(name, status_code, link_definition, operation)
|
332
156
|
|
333
157
|
|
334
158
|
StatusCode = Union[str, int]
|
@@ -2,6 +2,11 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from typing import TYPE_CHECKING, Any, Collection
|
4
4
|
|
5
|
+
from schemathesis.transport import SerializationContext
|
6
|
+
from schemathesis.transport.asgi import ASGI_TRANSPORT
|
7
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
8
|
+
from schemathesis.transport.wsgi import WSGI_TRANSPORT
|
9
|
+
|
5
10
|
if TYPE_CHECKING:
|
6
11
|
from hypothesis import strategies as st
|
7
12
|
|
@@ -11,15 +16,12 @@ MEDIA_TYPES: dict[str, st.SearchStrategy[bytes]] = {}
|
|
11
16
|
|
12
17
|
def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliases: Collection[str] = ()) -> None:
|
13
18
|
"""Register a strategy for the given media type."""
|
14
|
-
from ...serializers import SerializerContext, register
|
15
|
-
|
16
|
-
@register(name, aliases=aliases)
|
17
|
-
class MediaTypeSerializer:
|
18
|
-
def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
|
19
|
-
return {"data": value}
|
20
19
|
|
21
|
-
|
22
|
-
|
20
|
+
@REQUESTS_TRANSPORT.serializer(name, *aliases)
|
21
|
+
@ASGI_TRANSPORT.serializer(name, *aliases)
|
22
|
+
@WSGI_TRANSPORT.serializer(name, *aliases)
|
23
|
+
def serialize(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
24
|
+
return {"data": value}
|
23
25
|
|
24
26
|
MEDIA_TYPES[name] = strategy
|
25
27
|
for alias in aliases:
|
@@ -27,8 +29,4 @@ def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliase
|
|
27
29
|
|
28
30
|
|
29
31
|
def unregister_all() -> None:
|
30
|
-
from ...serializers import unregister
|
31
|
-
|
32
|
-
for media_type in MEDIA_TYPES:
|
33
|
-
unregister(media_type)
|
34
32
|
MEDIA_TYPES.clear()
|
@@ -11,7 +11,8 @@ from hypothesis import reject
|
|
11
11
|
from hypothesis import strategies as st
|
12
12
|
from hypothesis.strategies._internal.featureflags import FeatureStrategy
|
13
13
|
|
14
|
-
from
|
14
|
+
from schemathesis.core.transforms import deepclone
|
15
|
+
|
15
16
|
from ..utils import get_type, is_header_location
|
16
17
|
from .types import Draw, Schema
|
17
18
|
from .utils import can_negate
|
@@ -111,7 +112,7 @@ class MutationContext:
|
|
111
112
|
# Body can be of any type and does not have any specific type semantic.
|
112
113
|
mutations = draw(ordered(get_mutations(draw, self.keywords)))
|
113
114
|
# Deep copy all keywords to avoid modifying the original schema
|
114
|
-
new_schema =
|
115
|
+
new_schema = deepclone(self.keywords)
|
115
116
|
enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations")) # type: ignore
|
116
117
|
# Always apply at least one mutation, otherwise everything is rejected, and we'd like to avoid it
|
117
118
|
# for performance reasons
|
@@ -4,12 +4,13 @@ import json
|
|
4
4
|
from dataclasses import dataclass
|
5
5
|
from typing import TYPE_CHECKING, Any, ClassVar, Iterable
|
6
6
|
|
7
|
-
from
|
8
|
-
from
|
7
|
+
from schemathesis.core.errors import InvalidSchema
|
8
|
+
from schemathesis.schemas import Parameter
|
9
|
+
|
9
10
|
from .converter import to_json_schema_recursive
|
10
11
|
|
11
12
|
if TYPE_CHECKING:
|
12
|
-
from ...
|
13
|
+
from ...schemas import APIOperation
|
13
14
|
|
14
15
|
|
15
16
|
@dataclass(eq=False)
|
@@ -22,6 +23,7 @@ class OpenAPIParameter(Parameter):
|
|
22
23
|
supported_jsonschema_keywords: ClassVar[tuple[str, ...]]
|
23
24
|
|
24
25
|
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
26
|
+
|
25
27
|
@property
|
26
28
|
def description(self) -> str | None:
|
27
29
|
"""A brief parameter description."""
|
@@ -359,7 +361,7 @@ MISSING_SCHEMA_OR_CONTENT_MESSAGE = (
|
|
359
361
|
)
|
360
362
|
|
361
363
|
INVALID_SCHEMA_MESSAGE = (
|
362
|
-
'Can not generate data for {location} parameter "{name}"!
|
364
|
+
'Can not generate data for {location} parameter "{name}"! Its schema should be an object, got {schema}'
|
363
365
|
)
|
364
366
|
|
365
367
|
|
@@ -368,7 +370,7 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
|
|
368
370
|
# In Open API 3.0, there could be "schema" or "content" field. They are mutually exclusive.
|
369
371
|
if "schema" in data:
|
370
372
|
if not isinstance(data["schema"], dict):
|
371
|
-
raise
|
373
|
+
raise InvalidSchema(
|
372
374
|
INVALID_SCHEMA_MESSAGE.format(
|
373
375
|
location=data.get("in", ""), name=data.get("name", "<UNKNOWN>"), schema=data["schema"]
|
374
376
|
),
|
@@ -382,7 +384,7 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
|
|
382
384
|
try:
|
383
385
|
content = data["content"]
|
384
386
|
except KeyError as exc:
|
385
|
-
raise
|
387
|
+
raise InvalidSchema(
|
386
388
|
MISSING_SCHEMA_OR_CONTENT_MESSAGE.format(location=data.get("in", ""), name=data.get("name", "<UNKNOWN>")),
|
387
389
|
path=operation.path,
|
388
390
|
method=operation.method,
|
@@ -21,7 +21,7 @@ IN = sre.IN
|
|
21
21
|
MAXREPEAT = sre_parse.MAXREPEAT
|
22
22
|
|
23
23
|
|
24
|
-
@lru_cache
|
24
|
+
@lru_cache
|
25
25
|
def update_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
|
26
26
|
"""Update the quantifier of a regular expression based on given min and max lengths."""
|
27
27
|
if not pattern or (min_length in (None, 0) and max_length is None):
|