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.
Files changed (44) hide show
  1. schemathesis/checks.py +6 -4
  2. schemathesis/cli/__init__.py +12 -1
  3. schemathesis/cli/commands/run/__init__.py +4 -4
  4. schemathesis/cli/commands/run/events.py +19 -4
  5. schemathesis/cli/commands/run/executor.py +9 -3
  6. schemathesis/cli/commands/run/filters.py +27 -19
  7. schemathesis/cli/commands/run/handlers/base.py +1 -1
  8. schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
  9. schemathesis/cli/commands/run/handlers/output.py +860 -201
  10. schemathesis/cli/commands/run/validation.py +1 -1
  11. schemathesis/cli/ext/options.py +4 -1
  12. schemathesis/core/errors.py +8 -0
  13. schemathesis/core/failures.py +54 -24
  14. schemathesis/engine/core.py +1 -1
  15. schemathesis/engine/errors.py +11 -5
  16. schemathesis/engine/events.py +3 -97
  17. schemathesis/engine/phases/stateful/__init__.py +2 -0
  18. schemathesis/engine/phases/stateful/_executor.py +22 -50
  19. schemathesis/engine/phases/unit/__init__.py +1 -0
  20. schemathesis/engine/phases/unit/_executor.py +2 -1
  21. schemathesis/engine/phases/unit/_pool.py +1 -1
  22. schemathesis/engine/recorder.py +29 -23
  23. schemathesis/errors.py +19 -13
  24. schemathesis/generation/coverage.py +4 -4
  25. schemathesis/generation/hypothesis/builder.py +15 -12
  26. schemathesis/generation/stateful/state_machine.py +61 -45
  27. schemathesis/graphql/checks.py +3 -9
  28. schemathesis/openapi/checks.py +8 -33
  29. schemathesis/schemas.py +34 -14
  30. schemathesis/specs/graphql/schemas.py +16 -15
  31. schemathesis/specs/openapi/checks.py +50 -27
  32. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  33. schemathesis/specs/openapi/expressions/nodes.py +20 -20
  34. schemathesis/specs/openapi/links.py +139 -118
  35. schemathesis/specs/openapi/patterns.py +170 -2
  36. schemathesis/specs/openapi/schemas.py +60 -36
  37. schemathesis/specs/openapi/stateful/__init__.py +185 -113
  38. schemathesis/specs/openapi/stateful/control.py +87 -0
  39. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
  40. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
  41. schemathesis/specs/openapi/expressions/context.py +0 -14
  42. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
  43. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
  44. {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, field
9
- from difflib import get_close_matches
10
- from typing import TYPE_CHECKING, Any, Generator, Literal, TypedDict, Union, cast
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.generation.case import Case
14
- from schemathesis.generation.stateful.state_machine import Direction
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
- class SchemathesisLink(TypedDict):
29
- merge_body: bool
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
- NOTE. This class will replace `Link` in the future.
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
- definition: dict[str, Any]
42
- operation: APIOperation
43
- parameters: list[tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]] = field(init=False)
44
- body: dict[str, Any] | NotSet = field(init=False)
45
- merge_body: bool = True
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
- def normalize_parameter(
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
- Runtime expressions may have parameter names prefixed with their location - `path.id`.
132
- At the same time, parameters could be defined without a prefix - `id`.
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
- def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenAPILink], None, None]:
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, OpenAPILink(name, status_code, link_definition, operation)
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, ApiOperationsCount, BaseSchema, OperationDefinition
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 ...auths import AuthStorage
70
- from ...stateful.state_machine import APIStateMachine
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 _do_count_operations(self) -> ApiOperationsCount:
170
- counter = ApiOperationsCount()
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 counter
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
- if "$ref" in path_item:
182
- _, path_item = resolve(path_item["$ref"])
183
- for method, definition in path_item.items():
184
- if method not in HTTP_METHODS:
185
- continue
186
- counter.total += 1
187
- if not should_skip(path, method, definition):
188
- counter.selected += 1
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
- return counter
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
- try:
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,