schemathesis 4.0.0a3__py3-none-any.whl → 4.0.0a5__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/cli/__init__.py +3 -3
- schemathesis/cli/commands/run/__init__.py +159 -135
- schemathesis/cli/commands/run/checks.py +2 -3
- schemathesis/cli/commands/run/context.py +102 -19
- schemathesis/cli/commands/run/executor.py +33 -12
- schemathesis/cli/commands/run/filters.py +1 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +27 -46
- schemathesis/cli/commands/run/handlers/junitxml.py +1 -1
- schemathesis/cli/commands/run/handlers/output.py +238 -102
- schemathesis/cli/commands/run/hypothesis.py +14 -41
- schemathesis/cli/commands/run/reports.py +72 -0
- schemathesis/cli/commands/run/validation.py +18 -12
- schemathesis/cli/ext/groups.py +42 -13
- schemathesis/cli/ext/options.py +15 -8
- schemathesis/core/__init__.py +7 -1
- schemathesis/core/errors.py +79 -11
- schemathesis/core/failures.py +2 -1
- schemathesis/core/transforms.py +1 -1
- schemathesis/engine/config.py +2 -2
- schemathesis/engine/core.py +11 -1
- schemathesis/engine/errors.py +8 -3
- schemathesis/engine/events.py +7 -0
- schemathesis/engine/phases/__init__.py +16 -4
- schemathesis/engine/phases/stateful/_executor.py +1 -1
- schemathesis/engine/phases/unit/__init__.py +77 -53
- schemathesis/engine/phases/unit/_executor.py +28 -23
- schemathesis/engine/phases/unit/_pool.py +8 -0
- schemathesis/errors.py +6 -2
- schemathesis/experimental/__init__.py +0 -6
- schemathesis/filters.py +8 -0
- schemathesis/generation/coverage.py +6 -1
- schemathesis/generation/hypothesis/builder.py +222 -97
- schemathesis/generation/stateful/state_machine.py +49 -3
- schemathesis/openapi/checks.py +3 -1
- schemathesis/pytest/lazy.py +43 -5
- schemathesis/pytest/plugin.py +4 -4
- schemathesis/schemas.py +1 -1
- schemathesis/specs/openapi/checks.py +28 -11
- schemathesis/specs/openapi/examples.py +2 -5
- schemathesis/specs/openapi/expressions/__init__.py +22 -6
- schemathesis/specs/openapi/expressions/nodes.py +15 -21
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/parameters.py +0 -2
- schemathesis/specs/openapi/patterns.py +24 -7
- schemathesis/specs/openapi/schemas.py +13 -13
- schemathesis/specs/openapi/serialization.py +14 -0
- schemathesis/specs/openapi/stateful/__init__.py +96 -23
- schemathesis/specs/openapi/{links.py → stateful/links.py} +60 -16
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/METADATA +7 -26
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/RECORD +53 -52
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/licenses/LICENSE +0 -0
schemathesis/pytest/plugin.py
CHANGED
@@ -108,7 +108,7 @@ class SchemathesisCase(PyCollector):
|
|
108
108
|
This implementation is based on the original one in pytest, but with slight adjustments
|
109
109
|
to produce tests out of hypothesis ones.
|
110
110
|
"""
|
111
|
-
from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, create_test
|
111
|
+
from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, HypothesisTestMode, create_test
|
112
112
|
|
113
113
|
is_trio_test = False
|
114
114
|
for mark in getattr(self.test_function, "pytestmark", []):
|
@@ -133,6 +133,7 @@ class SchemathesisCase(PyCollector):
|
|
133
133
|
operation=operation,
|
134
134
|
test_func=self.test_function,
|
135
135
|
config=HypothesisTestConfig(
|
136
|
+
modes=list(HypothesisTestMode),
|
136
137
|
given_kwargs=self.given_kwargs,
|
137
138
|
generation=self.schema.generation_config,
|
138
139
|
as_strategy_kwargs=as_strategy_kwargs,
|
@@ -149,11 +150,10 @@ class SchemathesisCase(PyCollector):
|
|
149
150
|
error = result.err()
|
150
151
|
funcobj = error.as_failing_test_function()
|
151
152
|
name = self.name
|
152
|
-
# `full_path` is always available in this case
|
153
153
|
if error.method:
|
154
|
-
name += f"[{error.method.upper()} {error.
|
154
|
+
name += f"[{error.method.upper()} {error.path}]"
|
155
155
|
else:
|
156
|
-
name += f"[{error.
|
156
|
+
name += f"[{error.path}]"
|
157
157
|
|
158
158
|
cls = self._get_class_parent()
|
159
159
|
definition: FunctionDefinition = FunctionDefinition.from_parent(
|
schemathesis/schemas.py
CHANGED
@@ -619,7 +619,7 @@ class APIOperation(Generic[P]):
|
|
619
619
|
|
620
620
|
def __post_init__(self) -> None:
|
621
621
|
if self.label is None:
|
622
|
-
self.label = f"{self.method.upper()} {self.
|
622
|
+
self.label = f"{self.method.upper()} {self.path}" # type: ignore
|
623
623
|
|
624
624
|
@property
|
625
625
|
def full_path(self) -> str:
|
@@ -41,11 +41,20 @@ if TYPE_CHECKING:
|
|
41
41
|
from ...schemas import APIOperation
|
42
42
|
|
43
43
|
|
44
|
+
def is_unexpected_http_status_case(case: Case) -> bool:
|
45
|
+
# Skip checks for requests using HTTP methods not defined in the API spec
|
46
|
+
return bool(
|
47
|
+
case.meta
|
48
|
+
and isinstance(case.meta.phase.data, CoveragePhaseData)
|
49
|
+
and case.meta.phase.data.description.startswith("Unspecified HTTP method")
|
50
|
+
)
|
51
|
+
|
52
|
+
|
44
53
|
@schemathesis.check
|
45
54
|
def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
46
55
|
from .schemas import BaseOpenAPISchema
|
47
56
|
|
48
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
57
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
49
58
|
return True
|
50
59
|
responses = case.operation.definition.raw.get("responses", {})
|
51
60
|
# "default" can be used as the default response object for all HTTP codes that are not covered individually
|
@@ -74,7 +83,7 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
|
|
74
83
|
def content_type_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
75
84
|
from .schemas import BaseOpenAPISchema
|
76
85
|
|
77
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
86
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
78
87
|
return True
|
79
88
|
documented_content_types = case.operation.schema.get_content_types(case.operation, response)
|
80
89
|
if not documented_content_types:
|
@@ -128,7 +137,7 @@ def response_headers_conformance(ctx: CheckContext, response: Response, case: Ca
|
|
128
137
|
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
|
129
138
|
from .schemas import BaseOpenAPISchema, OpenApi30, _maybe_raise_one_or_more
|
130
139
|
|
131
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
140
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
132
141
|
return True
|
133
142
|
resolved = case.operation.schema.get_headers(case.operation, response)
|
134
143
|
if not resolved:
|
@@ -208,7 +217,7 @@ def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | floa
|
|
208
217
|
def response_schema_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
209
218
|
from .schemas import BaseOpenAPISchema
|
210
219
|
|
211
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
220
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
212
221
|
return True
|
213
222
|
return case.operation.validate_response(response)
|
214
223
|
|
@@ -217,7 +226,11 @@ def response_schema_conformance(ctx: CheckContext, response: Response, case: Cas
|
|
217
226
|
def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
218
227
|
from .schemas import BaseOpenAPISchema
|
219
228
|
|
220
|
-
if
|
229
|
+
if (
|
230
|
+
not isinstance(case.operation.schema, BaseOpenAPISchema)
|
231
|
+
or case.meta is None
|
232
|
+
or is_unexpected_http_status_case(case)
|
233
|
+
):
|
221
234
|
return True
|
222
235
|
|
223
236
|
config = ctx.config.get(negative_data_rejection, NegativeDataRejectionConfig())
|
@@ -241,7 +254,11 @@ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -
|
|
241
254
|
def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
242
255
|
from .schemas import BaseOpenAPISchema
|
243
256
|
|
244
|
-
if
|
257
|
+
if (
|
258
|
+
not isinstance(case.operation.schema, BaseOpenAPISchema)
|
259
|
+
or case.meta is None
|
260
|
+
or is_unexpected_http_status_case(case)
|
261
|
+
):
|
245
262
|
return True
|
246
263
|
|
247
264
|
config = ctx.config.get(positive_data_acceptance, PositiveDataAcceptanceConfig())
|
@@ -260,7 +277,7 @@ def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case)
|
|
260
277
|
def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
261
278
|
# NOTE: This check is intentionally not registered with `@schemathesis.check` because it is experimental
|
262
279
|
meta = case.meta
|
263
|
-
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData):
|
280
|
+
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or is_unexpected_http_status_case(case):
|
264
281
|
return None
|
265
282
|
data = meta.phase.data
|
266
283
|
if (
|
@@ -282,7 +299,7 @@ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -
|
|
282
299
|
|
283
300
|
def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
284
301
|
meta = case.meta
|
285
|
-
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData):
|
302
|
+
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or response.request.method == "OPTIONS":
|
286
303
|
return None
|
287
304
|
data = meta.phase.data
|
288
305
|
if data.description and data.description.startswith("Unspecified HTTP method:"):
|
@@ -332,7 +349,7 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
|
|
332
349
|
def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
333
350
|
from .schemas import BaseOpenAPISchema
|
334
351
|
|
335
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
352
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
336
353
|
return True
|
337
354
|
if response.status_code == 404 or response.status_code >= 500:
|
338
355
|
return None
|
@@ -373,7 +390,7 @@ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool |
|
|
373
390
|
def ensure_resource_availability(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
374
391
|
from .schemas import BaseOpenAPISchema
|
375
392
|
|
376
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
393
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
377
394
|
return True
|
378
395
|
|
379
396
|
# First, check if this is a 4XX response
|
@@ -453,7 +470,7 @@ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | No
|
|
453
470
|
"""Check if an operation declares authentication as a requirement but does not actually enforce it."""
|
454
471
|
from .schemas import BaseOpenAPISchema
|
455
472
|
|
456
|
-
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
473
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
|
457
474
|
return True
|
458
475
|
security_parameters = _get_security_parameters(case.operation)
|
459
476
|
# Authentication is required for this API operation and response is successful
|
@@ -16,6 +16,7 @@ from schemathesis.generation.case import Case
|
|
16
16
|
from schemathesis.generation.hypothesis import examples
|
17
17
|
from schemathesis.generation.meta import TestPhase
|
18
18
|
from schemathesis.schemas import APIOperation
|
19
|
+
from schemathesis.specs.openapi.serialization import get_serializers_for_operation
|
19
20
|
|
20
21
|
from ._hypothesis import get_default_format_strategies, openapi_cases
|
21
22
|
from .constants import LOCATION_TO_CONTAINER
|
@@ -50,11 +51,7 @@ def get_strategies_from_examples(
|
|
50
51
|
operation: APIOperation[OpenAPIParameter], **kwargs: Any
|
51
52
|
) -> list[SearchStrategy[Case]]:
|
52
53
|
"""Build a set of strategies that generate test cases based on explicit examples in the schema."""
|
53
|
-
maps =
|
54
|
-
for location, container in LOCATION_TO_CONTAINER.items():
|
55
|
-
serializer = operation.get_parameter_serializer(location)
|
56
|
-
if serializer is not None:
|
57
|
-
maps[container] = serializer
|
54
|
+
maps = get_serializers_for_operation(operation)
|
58
55
|
|
59
56
|
def serialize_components(case: Case) -> Case:
|
60
57
|
"""Applies special serialization rules for case components.
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
8
8
|
import json
|
9
9
|
from typing import Any
|
10
10
|
|
11
|
+
from schemathesis.core.transforms import UNRESOLVABLE, Unresolvable
|
11
12
|
from schemathesis.generation.stateful.state_machine import StepOutput
|
12
13
|
|
13
14
|
from . import lexer, nodes, parser
|
@@ -25,21 +26,36 @@ def evaluate(expr: Any, output: StepOutput, evaluate_nested: bool = False) -> An
|
|
25
26
|
parts = [node.evaluate(output) for node in parser.parse(expr)]
|
26
27
|
if len(parts) == 1:
|
27
28
|
return parts[0] # keep the return type the same as the internal value type
|
28
|
-
|
29
|
+
if any(isinstance(part, Unresolvable) for part in parts):
|
30
|
+
return UNRESOLVABLE
|
29
31
|
return "".join(str(part) for part in parts if part is not None)
|
30
32
|
|
31
33
|
|
32
34
|
def _evaluate_nested(expr: dict[str, Any] | list, output: StepOutput) -> Any:
|
33
35
|
if isinstance(expr, dict):
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
36
|
+
result_dict = {}
|
37
|
+
for key, value in expr.items():
|
38
|
+
new_key = _evaluate_object_key(key, output)
|
39
|
+
if new_key is UNRESOLVABLE:
|
40
|
+
return new_key
|
41
|
+
new_value = evaluate(value, output, evaluate_nested=True)
|
42
|
+
if new_value is UNRESOLVABLE:
|
43
|
+
return new_value
|
44
|
+
result_dict[new_key] = new_value
|
45
|
+
return result_dict
|
46
|
+
result_list = []
|
47
|
+
for item in expr:
|
48
|
+
new_value = evaluate(item, output, evaluate_nested=True)
|
49
|
+
if new_value is UNRESOLVABLE:
|
50
|
+
return new_value
|
51
|
+
result_list.append(new_value)
|
52
|
+
return result_list
|
39
53
|
|
40
54
|
|
41
55
|
def _evaluate_object_key(key: str, output: StepOutput) -> Any:
|
42
56
|
evaluated = evaluate(key, output)
|
57
|
+
if evaluated is UNRESOLVABLE:
|
58
|
+
return evaluated
|
43
59
|
if isinstance(evaluated, str):
|
44
60
|
return evaluated
|
45
61
|
if isinstance(evaluated, bool):
|
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, cast
|
|
8
8
|
|
9
9
|
from requests.structures import CaseInsensitiveDict
|
10
10
|
|
11
|
-
from schemathesis.core.transforms import UNRESOLVABLE, resolve_pointer
|
11
|
+
from schemathesis.core.transforms import UNRESOLVABLE, Unresolvable, resolve_pointer
|
12
12
|
from schemathesis.generation.stateful.state_machine import StepOutput
|
13
13
|
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
14
14
|
|
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
|
|
20
20
|
class Node:
|
21
21
|
"""Generic expression node."""
|
22
22
|
|
23
|
-
def evaluate(self, output: StepOutput) -> str:
|
23
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
24
24
|
raise NotImplementedError
|
25
25
|
|
26
26
|
|
@@ -39,7 +39,7 @@ class String(Node):
|
|
39
39
|
|
40
40
|
value: str
|
41
41
|
|
42
|
-
def evaluate(self, output: StepOutput) -> str:
|
42
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
43
43
|
"""String tokens are passed as they are.
|
44
44
|
|
45
45
|
``foo{$request.path.id}``
|
@@ -53,7 +53,7 @@ class String(Node):
|
|
53
53
|
class URL(Node):
|
54
54
|
"""A node for `$url` expression."""
|
55
55
|
|
56
|
-
def evaluate(self, output: StepOutput) -> str:
|
56
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
57
57
|
import requests
|
58
58
|
|
59
59
|
base_url = output.case.operation.base_url or "http://127.0.0.1"
|
@@ -66,7 +66,7 @@ class URL(Node):
|
|
66
66
|
class Method(Node):
|
67
67
|
"""A node for `$method` expression."""
|
68
68
|
|
69
|
-
def evaluate(self, output: StepOutput) -> str:
|
69
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
70
70
|
return output.case.operation.method.upper()
|
71
71
|
|
72
72
|
|
@@ -74,7 +74,7 @@ class Method(Node):
|
|
74
74
|
class StatusCode(Node):
|
75
75
|
"""A node for `$statusCode` expression."""
|
76
76
|
|
77
|
-
def evaluate(self, output: StepOutput) -> str:
|
77
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
78
78
|
return str(output.response.status_code)
|
79
79
|
|
80
80
|
|
@@ -86,7 +86,7 @@ class NonBodyRequest(Node):
|
|
86
86
|
parameter: str
|
87
87
|
extractor: Extractor | None = None
|
88
88
|
|
89
|
-
def evaluate(self, output: StepOutput) -> str:
|
89
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
90
90
|
container: dict | CaseInsensitiveDict = {
|
91
91
|
"query": output.case.query,
|
92
92
|
"path": output.case.path_parameters,
|
@@ -96,9 +96,9 @@ class NonBodyRequest(Node):
|
|
96
96
|
container = CaseInsensitiveDict(container)
|
97
97
|
value = container.get(self.parameter)
|
98
98
|
if value is None:
|
99
|
-
return
|
99
|
+
return UNRESOLVABLE
|
100
100
|
if self.extractor is not None:
|
101
|
-
return self.extractor.extract(value) or
|
101
|
+
return self.extractor.extract(value) or UNRESOLVABLE
|
102
102
|
return value
|
103
103
|
|
104
104
|
|
@@ -108,14 +108,11 @@ class BodyRequest(Node):
|
|
108
108
|
|
109
109
|
pointer: str | None = None
|
110
110
|
|
111
|
-
def evaluate(self, output: StepOutput) -> Any:
|
111
|
+
def evaluate(self, output: StepOutput) -> Any | Unresolvable:
|
112
112
|
document = output.case.body
|
113
113
|
if self.pointer is None:
|
114
114
|
return document
|
115
|
-
|
116
|
-
if resolved is UNRESOLVABLE:
|
117
|
-
return None
|
118
|
-
return resolved
|
115
|
+
return resolve_pointer(document, self.pointer[1:])
|
119
116
|
|
120
117
|
|
121
118
|
@dataclass
|
@@ -125,12 +122,12 @@ class HeaderResponse(Node):
|
|
125
122
|
parameter: str
|
126
123
|
extractor: Extractor | None = None
|
127
124
|
|
128
|
-
def evaluate(self, output: StepOutput) -> str:
|
125
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
129
126
|
value = output.response.headers.get(self.parameter.lower())
|
130
127
|
if value is None:
|
131
|
-
return
|
128
|
+
return UNRESOLVABLE
|
132
129
|
if self.extractor is not None:
|
133
|
-
return self.extractor.extract(value[0]) or
|
130
|
+
return self.extractor.extract(value[0]) or UNRESOLVABLE
|
134
131
|
return value[0]
|
135
132
|
|
136
133
|
|
@@ -145,7 +142,4 @@ class BodyResponse(Node):
|
|
145
142
|
if self.pointer is None:
|
146
143
|
# We need the parsed document - data will be serialized before sending to the application
|
147
144
|
return document
|
148
|
-
|
149
|
-
if resolved is UNRESOLVABLE:
|
150
|
-
return None
|
151
|
-
return resolved
|
145
|
+
return resolve_pointer(document, self.pointer[1:])
|
@@ -46,7 +46,7 @@ def _parse_variable(tokens: lexer.TokenGenerator, token: lexer.Token, expr: str)
|
|
46
46
|
elif token.value == nodes.NodeType.RESPONSE.value:
|
47
47
|
yield _parse_response(tokens, expr)
|
48
48
|
else:
|
49
|
-
raise UnknownToken(token.value)
|
49
|
+
raise UnknownToken(f"Invalid expression `{expr}`. Unknown token: `{token.value}`")
|
50
50
|
|
51
51
|
|
52
52
|
def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> nodes.BodyRequest | nodes.NonBodyRequest:
|
@@ -376,7 +376,6 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
|
|
376
376
|
),
|
377
377
|
path=operation.path,
|
378
378
|
method=operation.method,
|
379
|
-
full_path=operation.full_path,
|
380
379
|
)
|
381
380
|
return data["schema"]
|
382
381
|
# https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#fixed-fields-10
|
@@ -388,7 +387,6 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
|
|
388
387
|
MISSING_SCHEMA_OR_CONTENT_MESSAGE.format(location=data.get("in", ""), name=data.get("name", "<UNKNOWN>")),
|
389
388
|
path=operation.path,
|
390
389
|
method=operation.method,
|
391
|
-
full_path=operation.full_path,
|
392
390
|
) from exc
|
393
391
|
options = iter(content.values())
|
394
392
|
media_type_object = next(options)
|
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|
3
3
|
import re
|
4
4
|
from functools import lru_cache
|
5
5
|
|
6
|
+
from schemathesis.core.errors import InternalError
|
7
|
+
|
6
8
|
try: # pragma: no cover
|
7
9
|
import re._constants as sre
|
8
10
|
import re._parser as sre_parse
|
@@ -29,7 +31,15 @@ def update_quantifier(pattern: str, min_length: int | None, max_length: int | No
|
|
29
31
|
|
30
32
|
try:
|
31
33
|
parsed = sre_parse.parse(pattern)
|
32
|
-
|
34
|
+
updated = _handle_parsed_pattern(parsed, pattern, min_length, max_length)
|
35
|
+
try:
|
36
|
+
re.compile(updated)
|
37
|
+
except re.error as exc:
|
38
|
+
raise InternalError(
|
39
|
+
f"The combination of min_length={min_length} and max_length={max_length} applied to the original pattern '{pattern}' resulted in an invalid regex: '{updated}'. "
|
40
|
+
"This indicates a bug in the regex quantifier merging logic"
|
41
|
+
) from exc
|
42
|
+
return updated
|
33
43
|
except re.error:
|
34
44
|
# Invalid pattern
|
35
45
|
return pattern
|
@@ -261,13 +271,18 @@ def _handle_repeat_quantifier(
|
|
261
271
|
min_length, max_length = _build_size(min_repeat, max_repeat, min_length, max_length)
|
262
272
|
if min_length > max_length:
|
263
273
|
return pattern
|
264
|
-
|
274
|
+
inner = _strip_quantifier(pattern)
|
275
|
+
if inner.startswith("(") and inner.endswith(")"):
|
276
|
+
inner = inner[1:-1]
|
277
|
+
return f"({inner})" + _build_quantifier(min_length, max_length)
|
265
278
|
|
266
279
|
|
267
280
|
def _handle_literal_or_in_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
|
268
281
|
"""Handle literal or character class quantifiers."""
|
269
282
|
min_length = 1 if min_length is None else max(min_length, 1)
|
270
|
-
|
283
|
+
if pattern.startswith("(") and pattern.endswith(")"):
|
284
|
+
pattern = pattern[1:-1]
|
285
|
+
return f"({pattern})" + _build_quantifier(min_length, max_length)
|
271
286
|
|
272
287
|
|
273
288
|
def _build_quantifier(minimum: int | None, maximum: int | None) -> str:
|
@@ -294,10 +309,12 @@ def _build_size(min_repeat: int, max_repeat: int, min_length: int | None, max_le
|
|
294
309
|
def _strip_quantifier(pattern: str) -> str:
|
295
310
|
"""Remove quantifier from the pattern."""
|
296
311
|
# Lazy & posessive quantifiers
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
312
|
+
for marker in ("*?", "+?", "??", "*+", "?+", "++"):
|
313
|
+
if pattern.endswith(marker) and not pattern.endswith(rf"\{marker}"):
|
314
|
+
return pattern[:-2]
|
315
|
+
for marker in ("?", "*", "+"):
|
316
|
+
if pattern.endswith(marker) and not pattern.endswith(rf"\{marker}"):
|
317
|
+
pattern = pattern[:-1]
|
301
318
|
if pattern.endswith("}") and "{" in pattern:
|
302
319
|
# Find the start of the exact quantifier and drop everything since that index
|
303
320
|
idx = pattern.rfind("{")
|
@@ -41,11 +41,12 @@ from schemathesis.generation.case import Case
|
|
41
41
|
from schemathesis.generation.meta import CaseMetadata
|
42
42
|
from schemathesis.generation.overrides import Override, OverrideMark, check_no_override_mark
|
43
43
|
from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
|
44
|
+
from schemathesis.specs.openapi.stateful import links
|
44
45
|
|
45
46
|
from ...generation import GenerationConfig, GenerationMode
|
46
47
|
from ...hooks import HookContext, HookDispatcher
|
47
48
|
from ...schemas import APIOperation, APIOperationMap, ApiStatistic, BaseSchema, OperationDefinition
|
48
|
-
from . import
|
49
|
+
from . import serialization
|
49
50
|
from ._cache import OperationCache
|
50
51
|
from ._hypothesis import openapi_cases
|
51
52
|
from .converter import to_json_schema, to_json_schema_recursive
|
@@ -155,7 +156,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
155
156
|
return True
|
156
157
|
if self.filter_set.is_empty():
|
157
158
|
return False
|
158
|
-
path = self.get_full_path(path)
|
159
159
|
# Attribute assignment is way faster than creating a new namespace every time
|
160
160
|
operation = _ctx_cache.operation
|
161
161
|
operation.method = method
|
@@ -365,28 +365,24 @@ class BaseOpenAPISchema(BaseSchema):
|
|
365
365
|
def _into_err(self, error: Exception, path: str | None, method: str | None) -> Err[InvalidSchema]:
|
366
366
|
__tracebackhide__ = True
|
367
367
|
try:
|
368
|
-
|
369
|
-
self._raise_invalid_schema(error, full_path, path, method)
|
368
|
+
self._raise_invalid_schema(error, path, method)
|
370
369
|
except InvalidSchema as exc:
|
371
370
|
return Err(exc)
|
372
371
|
|
373
372
|
def _raise_invalid_schema(
|
374
373
|
self,
|
375
374
|
error: Exception,
|
376
|
-
full_path: str | None = None,
|
377
375
|
path: str | None = None,
|
378
376
|
method: str | None = None,
|
379
377
|
) -> NoReturn:
|
380
378
|
__tracebackhide__ = True
|
381
379
|
if isinstance(error, RefResolutionError):
|
382
|
-
raise InvalidSchema.from_reference_resolution_error(
|
383
|
-
error, path=path, method=method, full_path=full_path
|
384
|
-
) from None
|
380
|
+
raise InvalidSchema.from_reference_resolution_error(error, path=path, method=method) from None
|
385
381
|
try:
|
386
382
|
self.validate()
|
387
383
|
except jsonschema.ValidationError as exc:
|
388
|
-
raise InvalidSchema.from_jsonschema_error(exc, path=path, method=method
|
389
|
-
raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method
|
384
|
+
raise InvalidSchema.from_jsonschema_error(exc, path=path, method=method) from None
|
385
|
+
raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method) from error
|
390
386
|
|
391
387
|
def validate(self) -> None:
|
392
388
|
with suppress(TypeError):
|
@@ -584,8 +580,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
584
580
|
responses = operation.definition.raw["responses"]
|
585
581
|
except KeyError as exc:
|
586
582
|
path = operation.path
|
587
|
-
|
588
|
-
self._raise_invalid_schema(exc, full_path, path, operation.method)
|
583
|
+
self._raise_invalid_schema(exc, path, operation.method)
|
589
584
|
status_code = str(response.status_code)
|
590
585
|
if status_code in responses:
|
591
586
|
return self.resolver.resolve_in_scope(responses[status_code], operation.definition.scope)
|
@@ -654,7 +649,12 @@ class BaseOpenAPISchema(BaseSchema):
|
|
654
649
|
def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
|
655
650
|
result: dict[str, dict[str, Any]] = defaultdict(dict)
|
656
651
|
for status_code, link in links.get_all_links(operation):
|
657
|
-
|
652
|
+
if isinstance(link, Ok):
|
653
|
+
name = link.ok().name
|
654
|
+
else:
|
655
|
+
name = link.err().name
|
656
|
+
result[status_code][name] = link
|
657
|
+
|
658
658
|
return result
|
659
659
|
|
660
660
|
def get_tags(self, operation: APIOperation) -> list[str] | None:
|
@@ -3,12 +3,24 @@ from __future__ import annotations
|
|
3
3
|
import json
|
4
4
|
from typing import Any, Callable, Dict, Generator, List
|
5
5
|
|
6
|
+
from schemathesis.schemas import APIOperation
|
7
|
+
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
8
|
+
|
6
9
|
Generated = Dict[str, Any]
|
7
10
|
Definition = Dict[str, Any]
|
8
11
|
DefinitionList = List[Definition]
|
9
12
|
MapFunction = Callable[[Generated], Generated]
|
10
13
|
|
11
14
|
|
15
|
+
def get_serializers_for_operation(operation: APIOperation) -> dict[str, Callable]:
|
16
|
+
serializers = {}
|
17
|
+
for location, container in LOCATION_TO_CONTAINER.items():
|
18
|
+
serializer = operation.get_parameter_serializer(location)
|
19
|
+
if serializer is not None:
|
20
|
+
serializers[container] = serializer
|
21
|
+
return serializers
|
22
|
+
|
23
|
+
|
12
24
|
def make_serializer(
|
13
25
|
func: Callable[[DefinitionList], Generator[Callable | None, None, None]],
|
14
26
|
) -> Callable[[DefinitionList], Callable | None]:
|
@@ -16,6 +28,8 @@ def make_serializer(
|
|
16
28
|
|
17
29
|
def _wrapper(definitions: DefinitionList) -> Callable | None:
|
18
30
|
functions = list(func(definitions))
|
31
|
+
if not functions:
|
32
|
+
return None
|
19
33
|
|
20
34
|
def composed(x: Any) -> Any:
|
21
35
|
result = x
|