schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a3__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/checks.py +6 -4
- schemathesis/cli/__init__.py +12 -1
- schemathesis/cli/commands/run/__init__.py +4 -4
- schemathesis/cli/commands/run/events.py +19 -4
- schemathesis/cli/commands/run/executor.py +9 -3
- schemathesis/cli/commands/run/filters.py +27 -19
- schemathesis/cli/commands/run/handlers/base.py +1 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
- schemathesis/cli/commands/run/handlers/output.py +860 -201
- schemathesis/cli/commands/run/validation.py +1 -1
- schemathesis/cli/ext/options.py +4 -1
- schemathesis/core/errors.py +8 -0
- schemathesis/core/failures.py +54 -24
- schemathesis/engine/core.py +1 -1
- schemathesis/engine/errors.py +11 -5
- schemathesis/engine/events.py +3 -97
- schemathesis/engine/phases/stateful/__init__.py +2 -0
- schemathesis/engine/phases/stateful/_executor.py +22 -50
- schemathesis/engine/phases/unit/__init__.py +1 -0
- schemathesis/engine/phases/unit/_executor.py +2 -1
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/engine/recorder.py +29 -23
- schemathesis/errors.py +19 -13
- schemathesis/generation/coverage.py +4 -4
- schemathesis/generation/hypothesis/builder.py +15 -12
- schemathesis/generation/stateful/state_machine.py +61 -45
- schemathesis/graphql/checks.py +3 -9
- schemathesis/openapi/checks.py +8 -33
- schemathesis/schemas.py +34 -14
- schemathesis/specs/graphql/schemas.py +16 -15
- schemathesis/specs/openapi/checks.py +50 -27
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/nodes.py +20 -20
- schemathesis/specs/openapi/links.py +139 -118
- schemathesis/specs/openapi/patterns.py +170 -2
- schemathesis/specs/openapi/schemas.py +60 -36
- schemathesis/specs/openapi/stateful/__init__.py +185 -113
- schemathesis/specs/openapi/stateful/control.py +87 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
- schemathesis/specs/openapi/expressions/context.py +0 -14
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/licenses/LICENSE +0 -0
@@ -1,17 +1,13 @@
|
|
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 typing import TYPE_CHECKING, Any, Generator, Literal,
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from functools import lru_cache
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Generator, Literal, Union, cast
|
11
6
|
|
12
7
|
from schemathesis.core import NOT_SET, NotSet
|
13
|
-
from schemathesis.
|
14
|
-
from schemathesis.
|
8
|
+
from schemathesis.core.errors import InvalidLinkDefinition, InvalidSchema, OperationNotFound
|
9
|
+
from schemathesis.core.result import Err, Ok, Result
|
10
|
+
from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
|
15
11
|
from schemathesis.schemas import APIOperation
|
16
12
|
|
17
13
|
from . import expressions
|
@@ -23,129 +19,154 @@ if TYPE_CHECKING:
|
|
23
19
|
|
24
20
|
|
25
21
|
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
22
|
+
ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
|
26
23
|
|
27
24
|
|
28
|
-
|
29
|
-
|
25
|
+
@dataclass
|
26
|
+
class NormalizedParameter:
|
27
|
+
"""Processed link parameter with resolved container information."""
|
30
28
|
|
29
|
+
location: ParameterLocation | None
|
30
|
+
name: str
|
31
|
+
expression: str
|
32
|
+
container_name: str
|
33
|
+
|
34
|
+
__slots__ = ("location", "name", "expression", "container_name")
|
31
35
|
|
32
|
-
@dataclass(repr=False)
|
33
|
-
class OpenAPILink(Direction):
|
34
|
-
"""Alternative approach to link processing.
|
35
36
|
|
36
|
-
|
37
|
-
|
37
|
+
@dataclass(repr=False)
|
38
|
+
class OpenApiLink:
|
39
|
+
"""Represents an OpenAPI link between operations."""
|
38
40
|
|
39
41
|
name: str
|
40
42
|
status_code: str
|
41
|
-
|
42
|
-
|
43
|
-
parameters: list[
|
44
|
-
body: dict[str, Any] | NotSet
|
45
|
-
merge_body: bool
|
46
|
-
|
47
|
-
def __repr__(self) -> str:
|
48
|
-
path = self.operation.path
|
49
|
-
method = self.operation.method
|
50
|
-
return f"state.schema['{path}']['{method}'].links['{self.status_code}']['{self.name}']"
|
51
|
-
|
52
|
-
def __post_init__(self) -> None:
|
53
|
-
extension = self.definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
54
|
-
self.parameters = [
|
55
|
-
normalize_parameter(parameter, expression)
|
56
|
-
for parameter, expression in self.definition.get("parameters", {}).items()
|
57
|
-
]
|
58
|
-
self.body = self.definition.get("requestBody", NOT_SET)
|
59
|
-
if extension is not None:
|
60
|
-
self.merge_body = extension.get("merge_body", True)
|
61
|
-
|
62
|
-
def set_data(self, case: Case, **kwargs: Any) -> None:
|
63
|
-
"""Assign all linked definitions to the new case instance."""
|
64
|
-
context = kwargs["context"]
|
65
|
-
self.set_parameters(case, context)
|
66
|
-
self.set_body(case, context)
|
67
|
-
|
68
|
-
def set_parameters(self, case: Case, context: expressions.ExpressionContext) -> None:
|
69
|
-
for location, name, expression in self.parameters:
|
70
|
-
location, container = get_container(case, location, name)
|
71
|
-
# Might happen if there is directly specified container,
|
72
|
-
# but the schema has no parameters of such type at all.
|
73
|
-
# Therefore the container is empty, otherwise it will be at least an empty object
|
74
|
-
if container is None:
|
75
|
-
message = f"No such parameter in `{case.operation.method.upper()} {case.operation.path}`: `{name}`."
|
76
|
-
possibilities = [param.name for param in case.operation.iter_parameters()]
|
77
|
-
matches = get_close_matches(name, possibilities)
|
78
|
-
if matches:
|
79
|
-
message += f" Did you mean `{matches[0]}`?"
|
80
|
-
raise ValueError(message)
|
81
|
-
value = expressions.evaluate(expression, context)
|
82
|
-
if value is not None:
|
83
|
-
container[name] = value
|
84
|
-
|
85
|
-
def set_body(
|
86
|
-
self,
|
87
|
-
case: Case,
|
88
|
-
context: expressions.ExpressionContext,
|
89
|
-
) -> None:
|
90
|
-
if self.body is not NOT_SET:
|
91
|
-
evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
|
92
|
-
if self.merge_body:
|
93
|
-
case.body = merge_body(case.body, evaluated)
|
94
|
-
else:
|
95
|
-
case.body = evaluated
|
96
|
-
|
97
|
-
def get_target_operation(self) -> APIOperation:
|
98
|
-
if "operationId" in self.definition:
|
99
|
-
return self.operation.schema.get_operation_by_id(self.definition["operationId"]) # type: ignore
|
100
|
-
return self.operation.schema.get_operation_by_reference(self.definition["operationRef"]) # type: ignore
|
101
|
-
|
102
|
-
|
103
|
-
def merge_body(old: Any, new: Any) -> Any:
|
104
|
-
if isinstance(old, dict) and isinstance(new, dict):
|
105
|
-
return {**old, **new}
|
106
|
-
return new
|
107
|
-
|
108
|
-
|
109
|
-
def get_container(
|
110
|
-
case: Case, location: Literal["path", "query", "header", "cookie", "body"] | None, name: str
|
111
|
-
) -> tuple[Literal["path", "query", "header", "cookie", "body"], dict[str, Any] | None]:
|
112
|
-
"""Get a container that suppose to store the given parameter."""
|
113
|
-
if location:
|
114
|
-
container_name = LOCATION_TO_CONTAINER[location]
|
115
|
-
else:
|
116
|
-
for param in case.operation.iter_parameters():
|
117
|
-
if param.name == name:
|
118
|
-
location = param.location
|
119
|
-
container_name = LOCATION_TO_CONTAINER[param.location]
|
120
|
-
break
|
121
|
-
else:
|
122
|
-
raise ValueError(f"Parameter `{name}` is not defined in API operation `{case.operation.label}`")
|
123
|
-
return location, getattr(case, container_name)
|
124
|
-
|
43
|
+
source: APIOperation
|
44
|
+
target: APIOperation
|
45
|
+
parameters: list[NormalizedParameter]
|
46
|
+
body: dict[str, Any] | NotSet
|
47
|
+
merge_body: bool
|
125
48
|
|
126
|
-
|
127
|
-
parameter: str, expression: str
|
128
|
-
) -> tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]:
|
129
|
-
"""Normalize runtime expressions.
|
49
|
+
__slots__ = ("name", "status_code", "source", "target", "parameters", "body", "merge_body", "_cached_extract")
|
130
50
|
|
131
|
-
|
132
|
-
|
133
|
-
We need to normalize all parameters to the same form to simplify working with them.
|
134
|
-
"""
|
135
|
-
try:
|
136
|
-
# The parameter name is prefixed with its location. Example: `path.id`
|
137
|
-
location, name = tuple(parameter.split("."))
|
138
|
-
_location = cast(Literal["path", "query", "header", "cookie", "body"], location)
|
139
|
-
return _location, name, expression
|
140
|
-
except ValueError:
|
141
|
-
return None, parameter, expression
|
51
|
+
def __init__(self, name: str, status_code: str, definition: dict[str, Any], source: APIOperation):
|
52
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
142
53
|
|
54
|
+
self.name = name
|
55
|
+
self.status_code = status_code
|
56
|
+
self.source = source
|
57
|
+
assert isinstance(source.schema, BaseOpenAPISchema)
|
143
58
|
|
144
|
-
|
59
|
+
get_operation: Callable[[str], APIOperation]
|
60
|
+
if "operationId" in definition:
|
61
|
+
operation_reference = definition["operationId"]
|
62
|
+
get_operation = source.schema.get_operation_by_id
|
63
|
+
else:
|
64
|
+
operation_reference = definition["operationRef"]
|
65
|
+
get_operation = source.schema.get_operation_by_reference
|
66
|
+
|
67
|
+
try:
|
68
|
+
self.target = get_operation(operation_reference)
|
69
|
+
except OperationNotFound as exc:
|
70
|
+
raise InvalidLinkDefinition(
|
71
|
+
f"Link '{name}' references non-existent operation '{operation_reference}' from {status_code} response of '{source.label}'"
|
72
|
+
) from exc
|
73
|
+
|
74
|
+
extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
75
|
+
self.parameters = self._normalize_parameters(definition.get("parameters", {}))
|
76
|
+
self.body = definition.get("requestBody", NOT_SET)
|
77
|
+
self.merge_body = extension.get("merge_body", True) if extension else True
|
78
|
+
|
79
|
+
self._cached_extract = lru_cache(8)(self._extract_impl)
|
80
|
+
|
81
|
+
def _normalize_parameters(self, parameters: dict[str, str]) -> list[NormalizedParameter]:
|
82
|
+
"""Process link parameters and resolve their container locations.
|
83
|
+
|
84
|
+
Handles both explicit locations (e.g., "path.id") and implicit ones resolved from target operation.
|
85
|
+
"""
|
86
|
+
result = []
|
87
|
+
for parameter, expression in parameters.items():
|
88
|
+
location: ParameterLocation | None
|
89
|
+
try:
|
90
|
+
# The parameter name is prefixed with its location. Example: `path.id`
|
91
|
+
_location, name = tuple(parameter.split("."))
|
92
|
+
location = cast(ParameterLocation, _location)
|
93
|
+
except ValueError:
|
94
|
+
location = None
|
95
|
+
name = parameter
|
96
|
+
|
97
|
+
container_name = self._get_parameter_container(location, name)
|
98
|
+
result.append(NormalizedParameter(location, name, expression, container_name))
|
99
|
+
return result
|
100
|
+
|
101
|
+
def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
|
102
|
+
"""Resolve parameter container either from explicit location or by looking up in target operation."""
|
103
|
+
if location:
|
104
|
+
return LOCATION_TO_CONTAINER[location]
|
105
|
+
|
106
|
+
for param in self.target.iter_parameters():
|
107
|
+
if param.name == name:
|
108
|
+
return LOCATION_TO_CONTAINER[param.location]
|
109
|
+
raise InvalidSchema(f"Parameter `{name}` is not defined in API operation `{self.target.label}`")
|
110
|
+
|
111
|
+
def extract(self, output: StepOutput) -> Transition:
|
112
|
+
return self._cached_extract(StepOutputWrapper(output))
|
113
|
+
|
114
|
+
def _extract_impl(self, wrapper: StepOutputWrapper) -> Transition:
|
115
|
+
output = wrapper.output
|
116
|
+
return Transition(
|
117
|
+
id=f"{self.source.label} - {self.status_code} - {self.name}",
|
118
|
+
parent_id=output.case.id,
|
119
|
+
parameters=self.extract_parameters(output),
|
120
|
+
request_body=self.extract_body(output),
|
121
|
+
)
|
122
|
+
|
123
|
+
def extract_parameters(self, output: StepOutput) -> dict[str, dict[str, ExtractedParam]]:
|
124
|
+
"""Extract parameters using runtime expressions.
|
125
|
+
|
126
|
+
Returns a two-level dictionary: container -> parameter name -> extracted value
|
127
|
+
"""
|
128
|
+
extracted: dict[str, dict[str, ExtractedParam]] = {}
|
129
|
+
for parameter in self.parameters:
|
130
|
+
container = extracted.setdefault(parameter.container_name, {})
|
131
|
+
value: Result[Any, Exception]
|
132
|
+
try:
|
133
|
+
value = Ok(expressions.evaluate(parameter.expression, output))
|
134
|
+
except Exception as exc:
|
135
|
+
value = Err(exc)
|
136
|
+
container[parameter.name] = ExtractedParam(definition=parameter.expression, value=value)
|
137
|
+
return extracted
|
138
|
+
|
139
|
+
def extract_body(self, output: StepOutput) -> ExtractedParam | None:
|
140
|
+
if not isinstance(self.body, NotSet):
|
141
|
+
value: Result[Any, Exception]
|
142
|
+
try:
|
143
|
+
value = Ok(expressions.evaluate(self.body, output, evaluate_nested=True))
|
144
|
+
except Exception as exc:
|
145
|
+
value = Err(exc)
|
146
|
+
return ExtractedParam(definition=self.body, value=value)
|
147
|
+
return None
|
148
|
+
|
149
|
+
|
150
|
+
@dataclass
|
151
|
+
class StepOutputWrapper:
|
152
|
+
"""Wrapper for StepOutput that uses only case_id for hash-based caching."""
|
153
|
+
|
154
|
+
output: StepOutput
|
155
|
+
__slots__ = ("output",)
|
156
|
+
|
157
|
+
def __hash__(self) -> int:
|
158
|
+
return hash(self.output.case.id)
|
159
|
+
|
160
|
+
def __eq__(self, other: object) -> bool:
|
161
|
+
assert isinstance(other, StepOutputWrapper)
|
162
|
+
return self.output.case.id == other.output.case.id
|
163
|
+
|
164
|
+
|
165
|
+
def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenApiLink], None, None]:
|
145
166
|
for status_code, definition in operation.definition.raw["responses"].items():
|
146
167
|
definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
|
147
168
|
for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
|
148
|
-
yield status_code,
|
169
|
+
yield status_code, OpenApiLink(name, status_code, link_definition, operation)
|
149
170
|
|
150
171
|
|
151
172
|
StatusCode = Union[str, int]
|
@@ -66,9 +66,177 @@ def _handle_parsed_pattern(parsed: list, pattern: str, min_length: int | None, m
|
|
66
66
|
)
|
67
67
|
+ trailing_anchor
|
68
68
|
)
|
69
|
+
elif (
|
70
|
+
len(parsed) > 3
|
71
|
+
and parsed[0][0] == ANCHOR
|
72
|
+
and parsed[-1][0] == ANCHOR
|
73
|
+
and all(op == LITERAL or op in REPEATS for op, _ in parsed[1:-1])
|
74
|
+
):
|
75
|
+
return _handle_anchored_pattern(parsed, pattern, min_length, max_length)
|
69
76
|
return pattern
|
70
77
|
|
71
78
|
|
79
|
+
def _handle_anchored_pattern(parsed: list, pattern: str, min_length: int | None, max_length: int | None) -> str:
|
80
|
+
"""Update regex pattern with multiple quantified patterns to satisfy length constraints."""
|
81
|
+
# Extract anchors
|
82
|
+
leading_anchor_length = _get_anchor_length(parsed[0][1])
|
83
|
+
trailing_anchor_length = _get_anchor_length(parsed[-1][1])
|
84
|
+
leading_anchor = pattern[:leading_anchor_length]
|
85
|
+
trailing_anchor = pattern[-trailing_anchor_length:]
|
86
|
+
|
87
|
+
pattern_parts = parsed[1:-1]
|
88
|
+
|
89
|
+
# Adjust length constraints by subtracting fixed literals length
|
90
|
+
fixed_length = sum(1 for op, _ in pattern_parts if op == LITERAL)
|
91
|
+
if min_length is not None:
|
92
|
+
min_length -= fixed_length
|
93
|
+
if min_length < 0:
|
94
|
+
return pattern
|
95
|
+
if max_length is not None:
|
96
|
+
max_length -= fixed_length
|
97
|
+
if max_length < 0:
|
98
|
+
return pattern
|
99
|
+
|
100
|
+
# Extract only min/max bounds from quantified parts
|
101
|
+
quantifier_bounds = [value[:2] for op, value in pattern_parts if op in REPEATS]
|
102
|
+
|
103
|
+
if not quantifier_bounds:
|
104
|
+
return pattern
|
105
|
+
|
106
|
+
length_distribution = _distribute_length_constraints(quantifier_bounds, min_length, max_length)
|
107
|
+
if not length_distribution:
|
108
|
+
return pattern
|
109
|
+
|
110
|
+
# Rebuild pattern with updated quantifiers
|
111
|
+
result = leading_anchor
|
112
|
+
current_position = leading_anchor_length
|
113
|
+
distribution_idx = 0
|
114
|
+
|
115
|
+
for op, value in pattern_parts:
|
116
|
+
if op == LITERAL:
|
117
|
+
if pattern[current_position] == "\\":
|
118
|
+
# Escaped value
|
119
|
+
current_position += 2
|
120
|
+
result += "\\"
|
121
|
+
else:
|
122
|
+
current_position += 1
|
123
|
+
result += chr(value)
|
124
|
+
else:
|
125
|
+
new_min, new_max = length_distribution[distribution_idx]
|
126
|
+
next_position = _find_quantified_end(pattern, current_position)
|
127
|
+
quantified_segment = pattern[current_position:next_position]
|
128
|
+
_, _, subpattern = value
|
129
|
+
new_value = (new_min, new_max, subpattern)
|
130
|
+
|
131
|
+
result += _update_quantifier(op, new_value, quantified_segment, new_min, new_max)
|
132
|
+
current_position = next_position
|
133
|
+
distribution_idx += 1
|
134
|
+
|
135
|
+
return result + trailing_anchor
|
136
|
+
|
137
|
+
|
138
|
+
def _find_quantified_end(pattern: str, start: int) -> int:
|
139
|
+
"""Find the end position of current quantified part."""
|
140
|
+
char_class_level = 0
|
141
|
+
group_level = 0
|
142
|
+
|
143
|
+
for i in range(start, len(pattern)):
|
144
|
+
char = pattern[i]
|
145
|
+
|
146
|
+
# Handle character class nesting
|
147
|
+
if char == "[":
|
148
|
+
char_class_level += 1
|
149
|
+
elif char == "]":
|
150
|
+
char_class_level -= 1
|
151
|
+
|
152
|
+
# Handle group nesting
|
153
|
+
elif char == "(":
|
154
|
+
group_level += 1
|
155
|
+
elif char == ")":
|
156
|
+
group_level -= 1
|
157
|
+
|
158
|
+
# Only process quantifiers when we're not inside any nested structure
|
159
|
+
elif char_class_level == 0 and group_level == 0:
|
160
|
+
if char in "*+?":
|
161
|
+
return i + 1
|
162
|
+
elif char == "{":
|
163
|
+
# Find matching }
|
164
|
+
while i < len(pattern) and pattern[i] != "}":
|
165
|
+
i += 1
|
166
|
+
return i + 1
|
167
|
+
|
168
|
+
return len(pattern)
|
169
|
+
|
170
|
+
|
171
|
+
def _distribute_length_constraints(
|
172
|
+
bounds: list[tuple[int, int]], min_length: int | None, max_length: int | None
|
173
|
+
) -> list[tuple[int, int]] | None:
|
174
|
+
"""Distribute length constraints among quantified pattern parts."""
|
175
|
+
# Handle exact length case with dynamic programming
|
176
|
+
if min_length == max_length:
|
177
|
+
assert min_length is not None
|
178
|
+
target = min_length
|
179
|
+
dp: dict[tuple[int, int], list[tuple[int, ...]] | None] = {}
|
180
|
+
|
181
|
+
def find_valid_combination(pos: int, remaining: int) -> list[tuple[int, ...]] | None:
|
182
|
+
if (pos, remaining) in dp:
|
183
|
+
return dp[(pos, remaining)]
|
184
|
+
|
185
|
+
if pos == len(bounds):
|
186
|
+
return [()] if remaining == 0 else None
|
187
|
+
|
188
|
+
max_len: int
|
189
|
+
min_len, max_len = bounds[pos]
|
190
|
+
if max_len == MAXREPEAT:
|
191
|
+
max_len = remaining + 1
|
192
|
+
else:
|
193
|
+
max_len += 1
|
194
|
+
|
195
|
+
# Try each possible length for current quantifier
|
196
|
+
for length in range(min_len, max_len):
|
197
|
+
rest = find_valid_combination(pos + 1, remaining - length)
|
198
|
+
if rest is not None:
|
199
|
+
dp[(pos, remaining)] = [(length,) + r for r in rest]
|
200
|
+
return dp[(pos, remaining)]
|
201
|
+
|
202
|
+
dp[(pos, remaining)] = None
|
203
|
+
return None
|
204
|
+
|
205
|
+
distribution = find_valid_combination(0, target)
|
206
|
+
if distribution:
|
207
|
+
return [(length, length) for length in distribution[0]]
|
208
|
+
return None
|
209
|
+
|
210
|
+
# Handle range case by distributing min/max bounds
|
211
|
+
result = []
|
212
|
+
remaining_min = min_length or 0
|
213
|
+
remaining_max = max_length or MAXREPEAT
|
214
|
+
|
215
|
+
for min_repeat, max_repeat in bounds:
|
216
|
+
if remaining_min > 0:
|
217
|
+
part_min = min(max_repeat, max(min_repeat, remaining_min))
|
218
|
+
else:
|
219
|
+
part_min = min_repeat
|
220
|
+
|
221
|
+
if remaining_max < MAXREPEAT:
|
222
|
+
part_max = min(max_repeat, remaining_max)
|
223
|
+
else:
|
224
|
+
part_max = max_repeat
|
225
|
+
|
226
|
+
if part_min > part_max:
|
227
|
+
return None
|
228
|
+
|
229
|
+
result.append((part_min, part_max))
|
230
|
+
|
231
|
+
remaining_min = max(0, remaining_min - part_min)
|
232
|
+
remaining_max -= part_max if part_max != MAXREPEAT else 0
|
233
|
+
|
234
|
+
if remaining_min > 0 or remaining_max < 0:
|
235
|
+
return None
|
236
|
+
|
237
|
+
return result
|
238
|
+
|
239
|
+
|
72
240
|
def _get_anchor_length(node_type: int) -> int:
|
73
241
|
"""Determine the length of the anchor based on its type."""
|
74
242
|
if node_type in {sre.AT_BEGINNING_STRING, sre.AT_END_STRING, sre.AT_BOUNDARY, sre.AT_NON_BOUNDARY}:
|
@@ -93,13 +261,13 @@ def _handle_repeat_quantifier(
|
|
93
261
|
min_length, max_length = _build_size(min_repeat, max_repeat, min_length, max_length)
|
94
262
|
if min_length > max_length:
|
95
263
|
return pattern
|
96
|
-
return f"({_strip_quantifier(pattern)})" + _build_quantifier(min_length, max_length)
|
264
|
+
return f"({_strip_quantifier(pattern).strip(')(')})" + _build_quantifier(min_length, max_length)
|
97
265
|
|
98
266
|
|
99
267
|
def _handle_literal_or_in_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
|
100
268
|
"""Handle literal or character class quantifiers."""
|
101
269
|
min_length = 1 if min_length is None else max(min_length, 1)
|
102
|
-
return f"({pattern})" + _build_quantifier(min_length, max_length)
|
270
|
+
return f"({pattern.strip(')(')})" + _build_quantifier(min_length, max_length)
|
103
271
|
|
104
272
|
|
105
273
|
def _build_quantifier(minimum: int | None, maximum: int | None) -> str:
|
@@ -44,7 +44,7 @@ from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
|
|
44
44
|
|
45
45
|
from ...generation import GenerationConfig, GenerationMode
|
46
46
|
from ...hooks import HookContext, HookDispatcher
|
47
|
-
from ...schemas import APIOperation, APIOperationMap,
|
47
|
+
from ...schemas import APIOperation, APIOperationMap, ApiStatistic, BaseSchema, OperationDefinition
|
48
48
|
from . import links, serialization
|
49
49
|
from ._cache import OperationCache
|
50
50
|
from ._hypothesis import openapi_cases
|
@@ -66,8 +66,8 @@ from .stateful import create_state_machine
|
|
66
66
|
if TYPE_CHECKING:
|
67
67
|
from hypothesis.strategies import SearchStrategy
|
68
68
|
|
69
|
-
from
|
70
|
-
from
|
69
|
+
from schemathesis.auths import AuthStorage
|
70
|
+
from schemathesis.generation.stateful import APIStateMachine
|
71
71
|
|
72
72
|
HTTP_METHODS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"})
|
73
73
|
SCHEMA_ERROR_MESSAGE = "Ensure that the definition complies with the OpenAPI specification"
|
@@ -166,29 +166,73 @@ class BaseOpenAPISchema(BaseSchema):
|
|
166
166
|
operation.schema = self
|
167
167
|
return not self.filter_set.match(_ctx_cache)
|
168
168
|
|
169
|
-
def
|
170
|
-
|
169
|
+
def _measure_statistic(self) -> ApiStatistic:
|
170
|
+
statistic = ApiStatistic()
|
171
171
|
try:
|
172
172
|
paths = self.raw_schema["paths"]
|
173
173
|
except KeyError:
|
174
|
-
return
|
174
|
+
return statistic
|
175
175
|
|
176
176
|
resolve = self.resolver.resolve
|
177
|
+
resolve_path_item = self._resolve_path_item
|
177
178
|
should_skip = self._should_skip
|
179
|
+
links_field = self.links_field
|
180
|
+
|
181
|
+
# For operationId lookup
|
182
|
+
selected_operations_by_id: set[str] = set()
|
183
|
+
# Tuples of (method, path)
|
184
|
+
selected_operations_by_path: set[tuple[str, str]] = set()
|
185
|
+
collected_links: list[dict] = []
|
178
186
|
|
179
187
|
for path, path_item in paths.items():
|
180
188
|
try:
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
+
scope, path_item = resolve_path_item(path_item)
|
190
|
+
self.resolver.push_scope(scope)
|
191
|
+
try:
|
192
|
+
for method, definition in path_item.items():
|
193
|
+
if method not in HTTP_METHODS:
|
194
|
+
continue
|
195
|
+
statistic.operations.total += 1
|
196
|
+
is_selected = not should_skip(path, method, definition)
|
197
|
+
if is_selected:
|
198
|
+
statistic.operations.selected += 1
|
199
|
+
# Store both identifiers
|
200
|
+
if "operationId" in definition:
|
201
|
+
selected_operations_by_id.add(definition["operationId"])
|
202
|
+
selected_operations_by_path.add((method, path))
|
203
|
+
for response in definition.get("responses", {}).values():
|
204
|
+
if "$ref" in response:
|
205
|
+
_, response = resolve(response["$ref"])
|
206
|
+
defined_links = response.get(links_field)
|
207
|
+
if defined_links is not None:
|
208
|
+
statistic.links.total += len(defined_links)
|
209
|
+
if is_selected:
|
210
|
+
collected_links.extend(defined_links.values())
|
211
|
+
finally:
|
212
|
+
self.resolver.pop_scope()
|
189
213
|
except SCHEMA_PARSING_ERRORS:
|
190
214
|
continue
|
191
|
-
|
215
|
+
|
216
|
+
def is_link_selected(link: dict) -> bool:
|
217
|
+
if "$ref" in link:
|
218
|
+
_, link = resolve(link["$ref"])
|
219
|
+
|
220
|
+
if "operationId" in link:
|
221
|
+
return link["operationId"] in selected_operations_by_id
|
222
|
+
else:
|
223
|
+
try:
|
224
|
+
scope, _ = resolve(link["operationRef"])
|
225
|
+
path, method = scope.rsplit("/", maxsplit=2)[-2:]
|
226
|
+
path = path.replace("~1", "/").replace("~0", "~")
|
227
|
+
return (method, path) in selected_operations_by_path
|
228
|
+
except Exception:
|
229
|
+
return False
|
230
|
+
|
231
|
+
for link in collected_links:
|
232
|
+
if is_link_selected(link):
|
233
|
+
statistic.links.selected += 1
|
234
|
+
|
235
|
+
return statistic
|
192
236
|
|
193
237
|
def _operation_iter(self) -> Generator[dict[str, Any], None, None]:
|
194
238
|
try:
|
@@ -210,20 +254,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
210
254
|
# Ignore errors
|
211
255
|
continue
|
212
256
|
|
213
|
-
@property
|
214
|
-
def links_count(self) -> int:
|
215
|
-
total = 0
|
216
|
-
resolve = self.resolver.resolve
|
217
|
-
links_field = self.links_field
|
218
|
-
for definition in self._operation_iter():
|
219
|
-
for response in definition.get("responses", {}).values():
|
220
|
-
if "$ref" in response:
|
221
|
-
_, response = resolve(response["$ref"])
|
222
|
-
defined_links = response.get(links_field)
|
223
|
-
if defined_links is not None:
|
224
|
-
total += len(defined_links)
|
225
|
-
return total
|
226
|
-
|
227
257
|
def override(
|
228
258
|
self,
|
229
259
|
*,
|
@@ -573,13 +603,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
573
603
|
return scopes, definitions.get("headers")
|
574
604
|
|
575
605
|
def as_state_machine(self) -> type[APIStateMachine]:
|
576
|
-
|
577
|
-
return create_state_machine(self)
|
578
|
-
except OperationNotFound as exc:
|
579
|
-
raise LoaderError(
|
580
|
-
kind=LoaderErrorKind.OPEN_API_INVALID_SCHEMA,
|
581
|
-
message=f"Invalid Open API link definition: Operation `{exc.item}` not found",
|
582
|
-
) from exc
|
606
|
+
return create_state_machine(self)
|
583
607
|
|
584
608
|
def add_link(
|
585
609
|
self,
|