schemathesis 3.25.5__py3-none-any.whl → 3.39.7__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 +6 -6
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +4 -2
- schemathesis/_hypothesis.py +369 -56
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +5 -4
- schemathesis/_patches.py +21 -0
- schemathesis/_rate_limiter.py +7 -0
- schemathesis/_xml.py +75 -22
- schemathesis/auths.py +78 -16
- schemathesis/checks.py +21 -9
- schemathesis/cli/__init__.py +793 -448
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/callbacks.py +58 -13
- schemathesis/cli/cassettes.py +233 -47
- schemathesis/cli/constants.py +8 -2
- schemathesis/cli/context.py +24 -4
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +4 -1
- schemathesis/cli/junitxml.py +103 -22
- schemathesis/cli/options.py +15 -4
- schemathesis/cli/output/default.py +286 -115
- schemathesis/cli/output/short.py +25 -6
- schemathesis/cli/reporting.py +79 -0
- schemathesis/cli/sanitization.py +6 -0
- schemathesis/code_samples.py +5 -3
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +3 -3
- schemathesis/exceptions.py +76 -65
- schemathesis/experimental/__init__.py +35 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_flask.py +4 -1
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +17 -25
- schemathesis/failures.py +77 -9
- schemathesis/filters.py +185 -8
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +20 -36
- schemathesis/generation/_hypothesis.py +59 -0
- schemathesis/generation/_methods.py +44 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/graphql.py +0 -1
- schemathesis/hooks.py +89 -12
- schemathesis/internal/checks.py +84 -0
- schemathesis/internal/copy.py +22 -3
- schemathesis/internal/deprecation.py +6 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/internal/extensions.py +27 -0
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/output.py +68 -0
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +11 -0
- schemathesis/lazy.py +138 -25
- schemathesis/loaders.py +7 -5
- schemathesis/models.py +323 -213
- schemathesis/parameters.py +4 -0
- schemathesis/runner/__init__.py +72 -22
- schemathesis/runner/events.py +86 -6
- schemathesis/runner/impl/context.py +104 -0
- schemathesis/runner/impl/core.py +447 -187
- schemathesis/runner/impl/solo.py +19 -29
- schemathesis/runner/impl/threadpool.py +70 -79
- schemathesis/{cli → runner}/probes.py +37 -25
- schemathesis/runner/serialization.py +150 -17
- schemathesis/sanitization.py +5 -1
- schemathesis/schemas.py +170 -102
- schemathesis/serializers.py +17 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +39 -6
- schemathesis/service/events.py +5 -1
- schemathesis/service/extensions.py +224 -0
- schemathesis/service/hosts.py +6 -2
- schemathesis/service/metadata.py +25 -0
- schemathesis/service/models.py +211 -2
- schemathesis/service/report.py +6 -6
- schemathesis/service/serialization.py +60 -71
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +26 -0
- schemathesis/specs/graphql/loaders.py +25 -5
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +130 -100
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_cache.py +123 -0
- schemathesis/specs/openapi/_hypothesis.py +79 -61
- schemathesis/specs/openapi/checks.py +504 -25
- schemathesis/specs/openapi/converter.py +31 -4
- schemathesis/specs/openapi/definitions.py +10 -17
- schemathesis/specs/openapi/examples.py +143 -31
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +26 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +29 -6
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/links.py +125 -42
- schemathesis/specs/openapi/loaders.py +77 -36
- schemathesis/specs/openapi/media_types.py +34 -0
- schemathesis/specs/openapi/negative/__init__.py +6 -3
- schemathesis/specs/openapi/negative/mutations.py +21 -6
- schemathesis/specs/openapi/parameters.py +39 -25
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +37 -7
- schemathesis/specs/openapi/schemas.py +368 -242
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +198 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +14 -0
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +35 -21
- schemathesis/stateful/config.py +97 -0
- schemathesis/stateful/context.py +135 -0
- schemathesis/stateful/events.py +274 -0
- schemathesis/stateful/runner.py +309 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +67 -38
- schemathesis/stateful/statistic.py +22 -0
- schemathesis/stateful/validation.py +100 -0
- schemathesis/targets.py +33 -1
- schemathesis/throttling.py +25 -5
- schemathesis/transports/__init__.py +354 -0
- schemathesis/transports/asgi.py +7 -0
- schemathesis/transports/auth.py +25 -2
- schemathesis/transports/content_types.py +3 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +9 -4
- schemathesis/types.py +9 -0
- schemathesis/utils.py +11 -16
- schemathesis-3.39.7.dist-info/METADATA +293 -0
- schemathesis-3.39.7.dist-info/RECORD +160 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
schemathesis/failures.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import textwrap
|
|
3
4
|
from dataclasses import dataclass
|
|
4
|
-
from
|
|
5
|
-
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from schemathesis.internal.output import OutputConfig
|
|
6
8
|
|
|
7
9
|
if TYPE_CHECKING:
|
|
10
|
+
from json import JSONDecodeError
|
|
11
|
+
|
|
8
12
|
from graphql.error import GraphQLFormattedError
|
|
9
13
|
from jsonschema import ValidationError
|
|
10
14
|
|
|
@@ -41,16 +45,27 @@ class ValidationErrorContext(FailureContext):
|
|
|
41
45
|
return ("/".join(map(str, self.schema_path)),)
|
|
42
46
|
|
|
43
47
|
@classmethod
|
|
44
|
-
def from_exception(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
def from_exception(
|
|
49
|
+
cls, exc: ValidationError, *, output_config: OutputConfig | None = None
|
|
50
|
+
) -> ValidationErrorContext:
|
|
51
|
+
from .internal.output import truncate_json
|
|
52
|
+
|
|
53
|
+
output_config = OutputConfig.from_parent(output_config, max_lines=20)
|
|
54
|
+
schema = textwrap.indent(truncate_json(exc.schema, config=output_config), prefix=" ")
|
|
55
|
+
value = textwrap.indent(truncate_json(exc.instance, config=output_config), prefix=" ")
|
|
56
|
+
schema_path = list(exc.absolute_schema_path)
|
|
57
|
+
if len(schema_path) > 1:
|
|
58
|
+
# Exclude the last segment, which is already in the schema
|
|
59
|
+
schema_title = "Schema at "
|
|
60
|
+
for segment in schema_path[:-1]:
|
|
61
|
+
schema_title += f"/{segment}"
|
|
62
|
+
else:
|
|
63
|
+
schema_title = "Schema"
|
|
64
|
+
message = f"{exc.message}\n\n{schema_title}:\n\n{schema}\n\nValue:\n\n{value}"
|
|
50
65
|
return cls(
|
|
51
66
|
message=message,
|
|
52
67
|
validation_message=exc.message,
|
|
53
|
-
schema_path=
|
|
68
|
+
schema_path=schema_path,
|
|
54
69
|
schema=exc.schema,
|
|
55
70
|
instance_path=list(exc.absolute_path),
|
|
56
71
|
instance=exc.instance,
|
|
@@ -117,6 +132,59 @@ class UndefinedContentType(FailureContext):
|
|
|
117
132
|
type: str = "undefined_content_type"
|
|
118
133
|
|
|
119
134
|
|
|
135
|
+
@dataclass(repr=False)
|
|
136
|
+
class AcceptedNegativeData(FailureContext):
|
|
137
|
+
"""Response with negative data was accepted."""
|
|
138
|
+
|
|
139
|
+
message: str
|
|
140
|
+
status_code: int
|
|
141
|
+
allowed_statuses: list[str]
|
|
142
|
+
title: str = "Accepted negative data"
|
|
143
|
+
type: str = "accepted_negative_data"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(repr=False)
|
|
147
|
+
class RejectedPositiveData(FailureContext):
|
|
148
|
+
"""Response with positive data was rejected."""
|
|
149
|
+
|
|
150
|
+
message: str
|
|
151
|
+
status_code: int
|
|
152
|
+
allowed_statuses: list[str]
|
|
153
|
+
title: str = "Rejected positive data"
|
|
154
|
+
type: str = "rejected_positive_data"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(repr=False)
|
|
158
|
+
class UseAfterFree(FailureContext):
|
|
159
|
+
"""Resource was used after a successful DELETE operation on it."""
|
|
160
|
+
|
|
161
|
+
message: str
|
|
162
|
+
free: str
|
|
163
|
+
usage: str
|
|
164
|
+
title: str = "Use after free"
|
|
165
|
+
type: str = "use_after_free"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(repr=False)
|
|
169
|
+
class EnsureResourceAvailability(FailureContext):
|
|
170
|
+
"""Resource is not available immediately after creation."""
|
|
171
|
+
|
|
172
|
+
message: str
|
|
173
|
+
created_with: str
|
|
174
|
+
not_available_with: str
|
|
175
|
+
title: str = "Resource is not available after creation"
|
|
176
|
+
type: str = "ensure_resource_availability"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(repr=False)
|
|
180
|
+
class IgnoredAuth(FailureContext):
|
|
181
|
+
"""The API operation does not check the specified authentication."""
|
|
182
|
+
|
|
183
|
+
message: str
|
|
184
|
+
title: str = "Authentication declared but not enforced for this operation"
|
|
185
|
+
type: str = "ignored_auth"
|
|
186
|
+
|
|
187
|
+
|
|
120
188
|
@dataclass(repr=False)
|
|
121
189
|
class UndefinedStatusCode(FailureContext):
|
|
122
190
|
"""Response has a status code that is not defined in the schema."""
|
schemathesis/filters.py
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
"""Filtering system that allows users to filter API operations based on certain criteria."""
|
|
2
|
+
|
|
2
3
|
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
3
6
|
import re
|
|
4
7
|
from dataclasses import dataclass, field
|
|
5
8
|
from functools import partial
|
|
6
9
|
from types import SimpleNamespace
|
|
7
|
-
from typing import TYPE_CHECKING, Callable, List,
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, List, Protocol, Union
|
|
8
11
|
|
|
9
12
|
from .exceptions import UsageError
|
|
13
|
+
from .types import Filter as FilterType
|
|
14
|
+
from .types import NotSet
|
|
10
15
|
|
|
11
16
|
if TYPE_CHECKING:
|
|
12
17
|
from .models import APIOperation
|
|
@@ -49,16 +54,21 @@ class Matcher:
|
|
|
49
54
|
func = partial(by_value_list, attribute=attribute, expected=expected)
|
|
50
55
|
else:
|
|
51
56
|
func = partial(by_value, attribute=attribute, expected=expected)
|
|
52
|
-
label = f"{attribute}={
|
|
57
|
+
label = f"{attribute}={expected!r}"
|
|
53
58
|
return cls(func, label=label, _hash=hash(label))
|
|
54
59
|
|
|
55
60
|
@classmethod
|
|
56
61
|
def for_regex(cls, attribute: str, regex: RegexValue) -> Matcher:
|
|
57
62
|
"""Matcher that checks whether the specified attribute has the provided regex."""
|
|
58
63
|
if isinstance(regex, str):
|
|
59
|
-
|
|
64
|
+
flags: re.RegexFlag | int
|
|
65
|
+
if attribute == "method":
|
|
66
|
+
flags = re.IGNORECASE
|
|
67
|
+
else:
|
|
68
|
+
flags = 0
|
|
69
|
+
regex = re.compile(regex, flags=flags)
|
|
60
70
|
func = partial(by_regex, attribute=attribute, regex=regex)
|
|
61
|
-
label = f"{attribute}_regex={
|
|
71
|
+
label = f"{attribute}_regex={regex!r}"
|
|
62
72
|
return cls(func, label=label, _hash=hash(label))
|
|
63
73
|
|
|
64
74
|
def match(self, ctx: HasAPIOperation) -> bool:
|
|
@@ -69,6 +79,8 @@ class Matcher:
|
|
|
69
79
|
def get_operation_attribute(operation: APIOperation, attribute: str) -> str | list[str] | None:
|
|
70
80
|
if attribute == "tag":
|
|
71
81
|
return operation.tags
|
|
82
|
+
if attribute == "operation_id":
|
|
83
|
+
return operation.definition.raw.get("operationId")
|
|
72
84
|
# Just uppercase `method`
|
|
73
85
|
value = getattr(operation, attribute)
|
|
74
86
|
if attribute == "method":
|
|
@@ -99,8 +111,8 @@ def by_regex(ctx: HasAPIOperation, attribute: str, regex: re.Pattern) -> bool:
|
|
|
99
111
|
if value is None:
|
|
100
112
|
return False
|
|
101
113
|
if isinstance(value, list):
|
|
102
|
-
return any(bool(regex.
|
|
103
|
-
return bool(regex.
|
|
114
|
+
return any(bool(regex.search(entry)) for entry in value)
|
|
115
|
+
return bool(regex.search(value))
|
|
104
116
|
|
|
105
117
|
|
|
106
118
|
@dataclass(repr=False, frozen=True)
|
|
@@ -109,6 +121,8 @@ class Filter:
|
|
|
109
121
|
|
|
110
122
|
matchers: tuple[Matcher, ...]
|
|
111
123
|
|
|
124
|
+
__slots__ = ("matchers",)
|
|
125
|
+
|
|
112
126
|
def __repr__(self) -> str:
|
|
113
127
|
inner = " && ".join(matcher.label for matcher in self.matchers)
|
|
114
128
|
return f"<{self.__class__.__name__}: [{inner}]>"
|
|
@@ -125,8 +139,34 @@ class Filter:
|
|
|
125
139
|
class FilterSet:
|
|
126
140
|
"""Combines multiple filters to apply inclusion and exclusion rules on API operations."""
|
|
127
141
|
|
|
128
|
-
_includes: set[Filter]
|
|
129
|
-
_excludes: set[Filter]
|
|
142
|
+
_includes: set[Filter]
|
|
143
|
+
_excludes: set[Filter]
|
|
144
|
+
|
|
145
|
+
__slots__ = ("_includes", "_excludes")
|
|
146
|
+
|
|
147
|
+
def __init__(self, _includes: set[Filter] | None = None, _excludes: set[Filter] | None = None) -> None:
|
|
148
|
+
self._includes = _includes or set()
|
|
149
|
+
self._excludes = _excludes or set()
|
|
150
|
+
|
|
151
|
+
def clone(self) -> FilterSet:
|
|
152
|
+
return FilterSet(_includes=self._includes.copy(), _excludes=self._excludes.copy())
|
|
153
|
+
|
|
154
|
+
def merge(self, other: FilterSet) -> FilterSet:
|
|
155
|
+
def _merge(lhs: set[Filter], rhs: set[Filter]) -> set[Filter]:
|
|
156
|
+
result = lhs.copy()
|
|
157
|
+
for new in rhs:
|
|
158
|
+
for old in lhs:
|
|
159
|
+
for new_matcher in new.matchers:
|
|
160
|
+
for old_matcher in old.matchers:
|
|
161
|
+
if "=" in new_matcher.label and "=" in old_matcher.label:
|
|
162
|
+
if new_matcher.label.split("=")[0] == old_matcher.label.split("=")[0]:
|
|
163
|
+
result.remove(old)
|
|
164
|
+
result.add(new)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
return FilterSet(
|
|
168
|
+
_includes=_merge(self._includes, other._includes), _excludes=_merge(self._excludes, other._excludes)
|
|
169
|
+
)
|
|
130
170
|
|
|
131
171
|
def apply_to(self, operations: list[APIOperation]) -> list[APIOperation]:
|
|
132
172
|
"""Get a filtered list of the given operations that match the filters."""
|
|
@@ -166,6 +206,8 @@ class FilterSet:
|
|
|
166
206
|
path_regex: RegexValue | None = None,
|
|
167
207
|
tag: FilterValue | None = None,
|
|
168
208
|
tag_regex: RegexValue | None = None,
|
|
209
|
+
operation_id: FilterValue | None = None,
|
|
210
|
+
operation_id_regex: RegexValue | None = None,
|
|
169
211
|
) -> None:
|
|
170
212
|
"""Add a new INCLUDE filter."""
|
|
171
213
|
self._add_filter(
|
|
@@ -179,6 +221,8 @@ class FilterSet:
|
|
|
179
221
|
path_regex=path_regex,
|
|
180
222
|
tag=tag,
|
|
181
223
|
tag_regex=tag_regex,
|
|
224
|
+
operation_id=operation_id,
|
|
225
|
+
operation_id_regex=operation_id_regex,
|
|
182
226
|
)
|
|
183
227
|
|
|
184
228
|
def exclude(
|
|
@@ -193,6 +237,8 @@ class FilterSet:
|
|
|
193
237
|
path_regex: RegexValue | None = None,
|
|
194
238
|
tag: FilterValue | None = None,
|
|
195
239
|
tag_regex: RegexValue | None = None,
|
|
240
|
+
operation_id: FilterValue | None = None,
|
|
241
|
+
operation_id_regex: RegexValue | None = None,
|
|
196
242
|
) -> None:
|
|
197
243
|
"""Add a new EXCLUDE filter."""
|
|
198
244
|
self._add_filter(
|
|
@@ -206,6 +252,8 @@ class FilterSet:
|
|
|
206
252
|
path_regex=path_regex,
|
|
207
253
|
tag=tag,
|
|
208
254
|
tag_regex=tag_regex,
|
|
255
|
+
operation_id=operation_id,
|
|
256
|
+
operation_id_regex=operation_id_regex,
|
|
209
257
|
)
|
|
210
258
|
|
|
211
259
|
def _add_filter(
|
|
@@ -221,6 +269,8 @@ class FilterSet:
|
|
|
221
269
|
path_regex: RegexValue | None = None,
|
|
222
270
|
tag: FilterValue | None = None,
|
|
223
271
|
tag_regex: RegexValue | None = None,
|
|
272
|
+
operation_id: FilterValue | None = None,
|
|
273
|
+
operation_id_regex: RegexValue | None = None,
|
|
224
274
|
) -> None:
|
|
225
275
|
matchers = []
|
|
226
276
|
if func is not None:
|
|
@@ -230,6 +280,7 @@ class FilterSet:
|
|
|
230
280
|
("method", method, method_regex),
|
|
231
281
|
("path", path, path_regex),
|
|
232
282
|
("tag", tag, tag_regex),
|
|
283
|
+
("operation_id", operation_id, operation_id_regex),
|
|
233
284
|
):
|
|
234
285
|
if expected is not None and regex is not None:
|
|
235
286
|
# To match anything the regex should match the expected value, hence passing them together is useless
|
|
@@ -274,8 +325,12 @@ def attach_filter_chain(
|
|
|
274
325
|
name_regex: str | None = None,
|
|
275
326
|
method: FilterValue | None = None,
|
|
276
327
|
method_regex: str | None = None,
|
|
328
|
+
tag: FilterValue | None = None,
|
|
329
|
+
tag_regex: RegexValue | None = None,
|
|
277
330
|
path: FilterValue | None = None,
|
|
278
331
|
path_regex: str | None = None,
|
|
332
|
+
operation_id: FilterValue | None = None,
|
|
333
|
+
operation_id_regex: RegexValue | None = None,
|
|
279
334
|
) -> Callable:
|
|
280
335
|
__tracebackhide__ = True
|
|
281
336
|
filter_func(
|
|
@@ -284,8 +339,12 @@ def attach_filter_chain(
|
|
|
284
339
|
name_regex=name_regex,
|
|
285
340
|
method=method,
|
|
286
341
|
method_regex=method_regex,
|
|
342
|
+
tag=tag,
|
|
343
|
+
tag_regex=tag_regex,
|
|
287
344
|
path=path,
|
|
288
345
|
path_regex=path_regex,
|
|
346
|
+
operation_id=operation_id,
|
|
347
|
+
operation_id_regex=operation_id_regex,
|
|
289
348
|
)
|
|
290
349
|
return target
|
|
291
350
|
|
|
@@ -293,3 +352,121 @@ def attach_filter_chain(
|
|
|
293
352
|
proxy.__name__ = attribute
|
|
294
353
|
|
|
295
354
|
setattr(target, attribute, proxy)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def is_deprecated(ctx: HasAPIOperation) -> bool:
|
|
358
|
+
return ctx.operation.definition.raw.get("deprecated") is True
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def filter_set_from_components(
|
|
362
|
+
*,
|
|
363
|
+
include: bool,
|
|
364
|
+
method: FilterType | None = None,
|
|
365
|
+
endpoint: FilterType | None = None,
|
|
366
|
+
tag: FilterType | None = None,
|
|
367
|
+
operation_id: FilterType | None = None,
|
|
368
|
+
skip_deprecated_operations: bool | None | NotSet = None,
|
|
369
|
+
parent: FilterSet | None = None,
|
|
370
|
+
) -> FilterSet:
|
|
371
|
+
def _is_defined(x: FilterType | None) -> bool:
|
|
372
|
+
return x is not None and not isinstance(x, NotSet)
|
|
373
|
+
|
|
374
|
+
def _prepare_filter(filter_: FilterType | None) -> RegexValue | None:
|
|
375
|
+
if filter_ is None or isinstance(filter_, NotSet):
|
|
376
|
+
return None
|
|
377
|
+
if isinstance(filter_, str):
|
|
378
|
+
return filter_
|
|
379
|
+
return "|".join(f"({f})" for f in filter_)
|
|
380
|
+
|
|
381
|
+
new = FilterSet()
|
|
382
|
+
|
|
383
|
+
if _is_defined(method) or _is_defined(endpoint) or _is_defined(tag) or _is_defined(operation_id):
|
|
384
|
+
new._add_filter(
|
|
385
|
+
include,
|
|
386
|
+
method_regex=_prepare_filter(method),
|
|
387
|
+
path_regex=_prepare_filter(endpoint),
|
|
388
|
+
tag_regex=_prepare_filter(tag),
|
|
389
|
+
operation_id_regex=_prepare_filter(operation_id),
|
|
390
|
+
)
|
|
391
|
+
if skip_deprecated_operations is True and not any(
|
|
392
|
+
matcher.label == is_deprecated.__name__ for exclude_ in new._excludes for matcher in exclude_.matchers
|
|
393
|
+
):
|
|
394
|
+
new.exclude(func=is_deprecated)
|
|
395
|
+
# Merge with the parent filter set
|
|
396
|
+
if parent is not None:
|
|
397
|
+
for include_ in parent._includes:
|
|
398
|
+
matchers = include_.matchers
|
|
399
|
+
ids = []
|
|
400
|
+
for idx, matcher in enumerate(matchers):
|
|
401
|
+
label = matcher.label
|
|
402
|
+
if (
|
|
403
|
+
(not isinstance(method, NotSet) and label.startswith("method_regex="))
|
|
404
|
+
or (not isinstance(endpoint, NotSet) and label.startswith("path_regex="))
|
|
405
|
+
or (not isinstance(tag, NotSet) and matcher.label.startswith("tag_regex="))
|
|
406
|
+
or (not isinstance(operation_id, NotSet) and matcher.label.startswith("operation_id_regex="))
|
|
407
|
+
):
|
|
408
|
+
ids.append(idx)
|
|
409
|
+
if ids:
|
|
410
|
+
matchers = tuple(matcher for idx, matcher in enumerate(matchers) if idx not in ids)
|
|
411
|
+
if matchers:
|
|
412
|
+
if new._includes:
|
|
413
|
+
existing = new._includes.pop()
|
|
414
|
+
matchers = existing.matchers + matchers
|
|
415
|
+
new._includes.add(Filter(matchers=matchers))
|
|
416
|
+
for exclude_ in parent._excludes:
|
|
417
|
+
matchers = exclude_.matchers
|
|
418
|
+
ids = []
|
|
419
|
+
for idx, matcher in enumerate(exclude_.matchers):
|
|
420
|
+
if skip_deprecated_operations is False and matcher.label == is_deprecated.__name__:
|
|
421
|
+
ids.append(idx)
|
|
422
|
+
if ids:
|
|
423
|
+
matchers = tuple(matcher for idx, matcher in enumerate(matchers) if idx not in ids)
|
|
424
|
+
if matchers:
|
|
425
|
+
new._excludes.add(exclude_)
|
|
426
|
+
return new
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def parse_expression(expression: str) -> tuple[str, str, Any]:
|
|
430
|
+
expression = expression.strip()
|
|
431
|
+
|
|
432
|
+
# Find the operator
|
|
433
|
+
for op in ("==", "!="):
|
|
434
|
+
try:
|
|
435
|
+
pointer, value = expression.split(op, 1)
|
|
436
|
+
break
|
|
437
|
+
except ValueError:
|
|
438
|
+
continue
|
|
439
|
+
else:
|
|
440
|
+
raise ValueError(f"Invalid expression: {expression}")
|
|
441
|
+
|
|
442
|
+
pointer = pointer.strip()
|
|
443
|
+
value = value.strip()
|
|
444
|
+
if not pointer or not value:
|
|
445
|
+
raise ValueError(f"Invalid expression: {expression}")
|
|
446
|
+
# Parse the JSON value
|
|
447
|
+
try:
|
|
448
|
+
return pointer, op, json.loads(value)
|
|
449
|
+
except json.JSONDecodeError:
|
|
450
|
+
# If it's not valid JSON, treat it as a string
|
|
451
|
+
return pointer, op, value
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def expression_to_filter_function(expression: str) -> Callable[[HasAPIOperation], bool]:
|
|
455
|
+
from .specs.openapi.references import resolve_pointer
|
|
456
|
+
|
|
457
|
+
pointer, op, value = parse_expression(expression)
|
|
458
|
+
|
|
459
|
+
if op == "==":
|
|
460
|
+
|
|
461
|
+
def filter_function(ctx: HasAPIOperation) -> bool:
|
|
462
|
+
definition = ctx.operation.definition.resolved
|
|
463
|
+
resolved = resolve_pointer(definition, pointer)
|
|
464
|
+
return resolved == value
|
|
465
|
+
else:
|
|
466
|
+
|
|
467
|
+
def filter_function(ctx: HasAPIOperation) -> bool:
|
|
468
|
+
definition = ctx.operation.definition.resolved
|
|
469
|
+
resolved = resolve_pointer(definition, pointer)
|
|
470
|
+
return resolved != value
|
|
471
|
+
|
|
472
|
+
return filter_function
|
schemathesis/fixups/__init__.py
CHANGED
schemathesis/fixups/fast_api.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from typing import Any
|
|
3
4
|
|
|
4
|
-
from ..hooks import HookContext
|
|
5
|
+
from ..hooks import HookContext, register, unregister
|
|
5
6
|
from ..hooks import is_installed as global_is_installed
|
|
6
|
-
from ..hooks import register, unregister
|
|
7
7
|
from ..internal.jsonschema import traverse_schema
|
|
8
8
|
|
|
9
9
|
|
schemathesis/fixups/utf8_bom.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
from typing import TYPE_CHECKING
|
|
2
2
|
|
|
3
3
|
from ..constants import BOM_MARK
|
|
4
|
-
from ..hooks import HookContext
|
|
4
|
+
from ..hooks import HookContext, register, unregister
|
|
5
5
|
from ..hooks import is_installed as global_is_installed
|
|
6
|
-
from ..hooks import register, unregister
|
|
7
6
|
|
|
8
7
|
if TYPE_CHECKING:
|
|
9
8
|
from ..models import Case
|
|
@@ -1,44 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
import random
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from enum import Enum
|
|
5
|
-
from typing import Union, Iterable
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class DataGenerationMethod(str, Enum):
|
|
9
|
-
"""Defines what data Schemathesis generates for tests."""
|
|
10
|
-
|
|
11
|
-
# Generate data, that fits the API schema
|
|
12
|
-
positive = "positive"
|
|
13
|
-
# Doesn't fit the API schema
|
|
14
|
-
negative = "negative"
|
|
15
|
-
|
|
16
|
-
@classmethod
|
|
17
|
-
def default(cls) -> DataGenerationMethod:
|
|
18
|
-
return cls.positive
|
|
19
|
-
|
|
20
|
-
@classmethod
|
|
21
|
-
def all(cls) -> list[DataGenerationMethod]:
|
|
22
|
-
return list(DataGenerationMethod)
|
|
23
2
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
DataGenerationMethod.negative: "N",
|
|
28
|
-
}[self]
|
|
29
|
-
|
|
30
|
-
@property
|
|
31
|
-
def is_negative(self) -> bool:
|
|
32
|
-
return self == DataGenerationMethod.negative
|
|
3
|
+
import random
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
33
6
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if isinstance(value, DataGenerationMethod):
|
|
37
|
-
return [value]
|
|
38
|
-
return list(value)
|
|
7
|
+
from ._hypothesis import add_single_example, combine_strategies, get_single_example
|
|
8
|
+
from ._methods import DataGenerationMethod, DataGenerationMethodInput
|
|
39
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from hypothesis.strategies import SearchStrategy
|
|
40
12
|
|
|
41
|
-
DataGenerationMethodInput = Union[DataGenerationMethod, Iterable[DataGenerationMethod]]
|
|
42
13
|
|
|
43
14
|
DEFAULT_DATA_GENERATION_METHODS = (DataGenerationMethod.default(),)
|
|
44
15
|
|
|
@@ -58,11 +29,24 @@ def generate_random_case_id(length: int = 6) -> str:
|
|
|
58
29
|
return output
|
|
59
30
|
|
|
60
31
|
|
|
32
|
+
@dataclass
|
|
33
|
+
class HeaderConfig:
|
|
34
|
+
"""Configuration for generating headers."""
|
|
35
|
+
|
|
36
|
+
strategy: SearchStrategy[str] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
61
39
|
@dataclass
|
|
62
40
|
class GenerationConfig:
|
|
63
41
|
"""Holds various configuration options relevant for data generation."""
|
|
64
42
|
|
|
65
43
|
# Allow generating `\x00` bytes in strings
|
|
66
44
|
allow_x00: bool = True
|
|
45
|
+
# Allowing using `null` for optional arguments in GraphQL queries
|
|
46
|
+
graphql_allow_null: bool = True
|
|
67
47
|
# Generate strings using the given codec
|
|
68
48
|
codec: str | None = "utf-8"
|
|
49
|
+
# Whether to generate security parameters
|
|
50
|
+
with_security_parameters: bool = True
|
|
51
|
+
# Header generation configuration
|
|
52
|
+
headers: HeaderConfig = field(default_factory=HeaderConfig)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from functools import lru_cache, reduce
|
|
5
|
+
from operator import or_
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from hypothesis import settings
|
|
10
|
+
from hypothesis import strategies as st
|
|
11
|
+
|
|
12
|
+
SCHEMATHESIS_BENCHMARK_SEED = os.environ.get("SCHEMATHESIS_BENCHMARK_SEED")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@lru_cache
|
|
16
|
+
def default_settings() -> settings:
|
|
17
|
+
from hypothesis import HealthCheck, Phase, Verbosity, settings
|
|
18
|
+
|
|
19
|
+
return settings(
|
|
20
|
+
database=None,
|
|
21
|
+
max_examples=1,
|
|
22
|
+
deadline=None,
|
|
23
|
+
verbosity=Verbosity.quiet,
|
|
24
|
+
phases=(Phase.generate,),
|
|
25
|
+
suppress_health_check=list(HealthCheck),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_single_example(strategy: st.SearchStrategy[T]) -> T: # type: ignore[type-var]
|
|
33
|
+
examples: list[T] = []
|
|
34
|
+
add_single_example(strategy, examples)
|
|
35
|
+
return examples[0]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add_single_example(strategy: st.SearchStrategy[T], examples: list[T]) -> None:
|
|
39
|
+
from hypothesis import given, seed
|
|
40
|
+
|
|
41
|
+
@given(strategy) # type: ignore
|
|
42
|
+
@default_settings() # type: ignore
|
|
43
|
+
def example_generating_inner_function(ex: T) -> None:
|
|
44
|
+
examples.append(ex)
|
|
45
|
+
|
|
46
|
+
example_generating_inner_function._hypothesis_internal_database_key = b"" # type: ignore
|
|
47
|
+
|
|
48
|
+
if SCHEMATHESIS_BENCHMARK_SEED is not None:
|
|
49
|
+
example_generating_inner_function = seed(SCHEMATHESIS_BENCHMARK_SEED)(example_generating_inner_function)
|
|
50
|
+
|
|
51
|
+
example_generating_inner_function()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def combine_strategies(strategies: list[st.SearchStrategy] | tuple[st.SearchStrategy]) -> st.SearchStrategy:
|
|
55
|
+
"""Combine a list of strategies into a single one.
|
|
56
|
+
|
|
57
|
+
If the input is `[a, b, c]`, then the result is equivalent to `a | b | c`.
|
|
58
|
+
"""
|
|
59
|
+
return reduce(or_, strategies[1:], strategies[0])
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Iterable, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DataGenerationMethod(str, Enum):
|
|
8
|
+
"""Defines what data Schemathesis generates for tests."""
|
|
9
|
+
|
|
10
|
+
# Generate data, that fits the API schema
|
|
11
|
+
positive = "positive"
|
|
12
|
+
# Doesn't fit the API schema
|
|
13
|
+
negative = "negative"
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def default(cls) -> DataGenerationMethod:
|
|
17
|
+
return cls.positive
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def all(cls) -> list[DataGenerationMethod]:
|
|
21
|
+
return list(DataGenerationMethod)
|
|
22
|
+
|
|
23
|
+
def as_short_name(self) -> str:
|
|
24
|
+
return {
|
|
25
|
+
DataGenerationMethod.positive: "P",
|
|
26
|
+
DataGenerationMethod.negative: "N",
|
|
27
|
+
}[self]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_positive(self) -> bool:
|
|
31
|
+
return self == DataGenerationMethod.positive
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_negative(self) -> bool:
|
|
35
|
+
return self == DataGenerationMethod.negative
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def ensure_list(cls, value: DataGenerationMethodInput) -> list[DataGenerationMethod]:
|
|
39
|
+
if isinstance(value, DataGenerationMethod):
|
|
40
|
+
return [value]
|
|
41
|
+
return list(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DataGenerationMethodInput = Union[DataGenerationMethod, Iterable[DataGenerationMethod]]
|