schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. schemathesis/checks.py +6 -4
  2. schemathesis/cli/commands/run/__init__.py +4 -4
  3. schemathesis/cli/commands/run/events.py +4 -9
  4. schemathesis/cli/commands/run/executor.py +6 -3
  5. schemathesis/cli/commands/run/filters.py +27 -19
  6. schemathesis/cli/commands/run/handlers/base.py +1 -1
  7. schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
  8. schemathesis/cli/commands/run/handlers/output.py +765 -143
  9. schemathesis/cli/commands/run/validation.py +1 -1
  10. schemathesis/cli/ext/options.py +4 -1
  11. schemathesis/core/failures.py +54 -24
  12. schemathesis/engine/core.py +1 -1
  13. schemathesis/engine/events.py +3 -97
  14. schemathesis/engine/phases/stateful/__init__.py +1 -0
  15. schemathesis/engine/phases/stateful/_executor.py +19 -44
  16. schemathesis/engine/phases/unit/__init__.py +1 -0
  17. schemathesis/engine/phases/unit/_executor.py +2 -1
  18. schemathesis/engine/phases/unit/_pool.py +1 -1
  19. schemathesis/engine/recorder.py +8 -3
  20. schemathesis/generation/stateful/state_machine.py +53 -36
  21. schemathesis/graphql/checks.py +3 -9
  22. schemathesis/openapi/checks.py +8 -33
  23. schemathesis/schemas.py +34 -14
  24. schemathesis/specs/graphql/schemas.py +16 -15
  25. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  26. schemathesis/specs/openapi/expressions/nodes.py +20 -20
  27. schemathesis/specs/openapi/links.py +126 -119
  28. schemathesis/specs/openapi/schemas.py +18 -22
  29. schemathesis/specs/openapi/stateful/__init__.py +77 -55
  30. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +1 -1
  31. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/RECORD +34 -35
  32. schemathesis/specs/openapi/expressions/context.py +0 -14
  33. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
  34. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +0 -0
  35. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -9,10 +9,10 @@ from typing import TYPE_CHECKING, Any, cast
9
9
  from requests.structures import CaseInsensitiveDict
10
10
 
11
11
  from schemathesis.core.transforms import UNRESOLVABLE, resolve_pointer
12
+ from schemathesis.generation.stateful.state_machine import StepOutput
12
13
  from schemathesis.transport.requests import REQUESTS_TRANSPORT
13
14
 
14
15
  if TYPE_CHECKING:
15
- from .context import ExpressionContext
16
16
  from .extractors import Extractor
17
17
 
18
18
 
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
20
20
  class Node:
21
21
  """Generic expression node."""
22
22
 
23
- def evaluate(self, context: ExpressionContext) -> str:
23
+ def evaluate(self, output: StepOutput) -> str:
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, context: ExpressionContext) -> str:
42
+ def evaluate(self, output: StepOutput) -> str:
43
43
  """String tokens are passed as they are.
44
44
 
45
45
  ``foo{$request.path.id}``
@@ -53,11 +53,11 @@ class String(Node):
53
53
  class URL(Node):
54
54
  """A node for `$url` expression."""
55
55
 
56
- def evaluate(self, context: ExpressionContext) -> str:
56
+ def evaluate(self, output: StepOutput) -> str:
57
57
  import requests
58
58
 
59
- base_url = context.case.operation.base_url or "http://127.0.0.1"
60
- kwargs = REQUESTS_TRANSPORT.serialize_case(context.case, base_url=base_url)
59
+ base_url = output.case.operation.base_url or "http://127.0.0.1"
60
+ kwargs = REQUESTS_TRANSPORT.serialize_case(output.case, base_url=base_url)
61
61
  prepared = requests.Request(**kwargs).prepare()
62
62
  return cast(str, prepared.url)
63
63
 
@@ -66,16 +66,16 @@ class URL(Node):
66
66
  class Method(Node):
67
67
  """A node for `$method` expression."""
68
68
 
69
- def evaluate(self, context: ExpressionContext) -> str:
70
- return context.case.operation.method.upper()
69
+ def evaluate(self, output: StepOutput) -> str:
70
+ return output.case.operation.method.upper()
71
71
 
72
72
 
73
73
  @dataclass
74
74
  class StatusCode(Node):
75
75
  """A node for `$statusCode` expression."""
76
76
 
77
- def evaluate(self, context: ExpressionContext) -> str:
78
- return str(context.response.status_code)
77
+ def evaluate(self, output: StepOutput) -> str:
78
+ return str(output.response.status_code)
79
79
 
80
80
 
81
81
  @dataclass
@@ -86,11 +86,11 @@ class NonBodyRequest(Node):
86
86
  parameter: str
87
87
  extractor: Extractor | None = None
88
88
 
89
- def evaluate(self, context: ExpressionContext) -> str:
89
+ def evaluate(self, output: StepOutput) -> str:
90
90
  container: dict | CaseInsensitiveDict = {
91
- "query": context.case.query,
92
- "path": context.case.path_parameters,
93
- "header": context.case.headers,
91
+ "query": output.case.query,
92
+ "path": output.case.path_parameters,
93
+ "header": output.case.headers,
94
94
  }[self.location] or {}
95
95
  if self.location == "header":
96
96
  container = CaseInsensitiveDict(container)
@@ -108,8 +108,8 @@ class BodyRequest(Node):
108
108
 
109
109
  pointer: str | None = None
110
110
 
111
- def evaluate(self, context: ExpressionContext) -> Any:
112
- document = context.case.body
111
+ def evaluate(self, output: StepOutput) -> Any:
112
+ document = output.case.body
113
113
  if self.pointer is None:
114
114
  return document
115
115
  resolved = resolve_pointer(document, self.pointer[1:])
@@ -125,8 +125,8 @@ class HeaderResponse(Node):
125
125
  parameter: str
126
126
  extractor: Extractor | None = None
127
127
 
128
- def evaluate(self, context: ExpressionContext) -> str:
129
- value = context.response.headers.get(self.parameter.lower())
128
+ def evaluate(self, output: StepOutput) -> str:
129
+ value = output.response.headers.get(self.parameter.lower())
130
130
  if value is None:
131
131
  return ""
132
132
  if self.extractor is not None:
@@ -140,8 +140,8 @@ class BodyResponse(Node):
140
140
 
141
141
  pointer: str | None = None
142
142
 
143
- def evaluate(self, context: ExpressionContext) -> Any:
144
- document = context.response.json()
143
+ def evaluate(self, output: StepOutput) -> Any:
144
+ document = output.response.json()
145
145
  if self.pointer is None:
146
146
  # We need the parsed document - data will be serialized before sending to the application
147
147
  return document
@@ -1,17 +1,12 @@
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, 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.result import Err, Ok, Result
9
+ from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
15
10
  from schemathesis.schemas import APIOperation
16
11
 
17
12
  from . import expressions
@@ -23,129 +18,141 @@ if TYPE_CHECKING:
23
18
 
24
19
 
25
20
  SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
21
+ ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
26
22
 
27
23
 
28
- class SchemathesisLink(TypedDict):
29
- merge_body: bool
24
+ @dataclass
25
+ class NormalizedParameter:
26
+ """Processed link parameter with resolved container information."""
27
+
28
+ location: ParameterLocation | None
29
+ name: str
30
+ expression: str
31
+ container_name: str
30
32
 
33
+ __slots__ = ("location", "name", "expression", "container_name")
31
34
 
32
- @dataclass(repr=False)
33
- class OpenAPILink(Direction):
34
- """Alternative approach to link processing.
35
35
 
36
- NOTE. This class will replace `Link` in the future.
37
- """
36
+ @dataclass(repr=False)
37
+ class OpenApiLink:
38
+ """Represents an OpenAPI link between operations."""
38
39
 
39
40
  name: str
40
41
  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
-
125
-
126
- def normalize_parameter(
127
- parameter: str, expression: str
128
- ) -> tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]:
129
- """Normalize runtime expressions.
42
+ source: APIOperation
43
+ target: APIOperation
44
+ parameters: list[NormalizedParameter]
45
+ body: dict[str, Any] | NotSet
46
+ merge_body: bool
130
47
 
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
48
+ __slots__ = ("name", "status_code", "source", "target", "parameters", "body", "merge_body", "_cached_extract")
142
49
 
50
+ def __init__(self, name: str, status_code: str, definition: dict[str, Any], source: APIOperation):
51
+ self.name = name
52
+ self.status_code = status_code
53
+ self.source = source
143
54
 
144
- def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenAPILink], None, None]:
55
+ if "operationId" in definition:
56
+ self.target = source.schema.get_operation_by_id(definition["operationId"]) # type: ignore
57
+ else:
58
+ self.target = source.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
59
+
60
+ extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
61
+ self.parameters = self._normalize_parameters(definition.get("parameters", {}))
62
+ self.body = definition.get("requestBody", NOT_SET)
63
+ self.merge_body = extension.get("merge_body", True) if extension else True
64
+
65
+ self._cached_extract = lru_cache(8)(self._extract_impl)
66
+
67
+ def _normalize_parameters(self, parameters: dict[str, str]) -> list[NormalizedParameter]:
68
+ """Process link parameters and resolve their container locations.
69
+
70
+ Handles both explicit locations (e.g., "path.id") and implicit ones resolved from target operation.
71
+ """
72
+ result = []
73
+ for parameter, expression in parameters.items():
74
+ location: ParameterLocation | None
75
+ try:
76
+ # The parameter name is prefixed with its location. Example: `path.id`
77
+ _location, name = tuple(parameter.split("."))
78
+ location = cast(ParameterLocation, _location)
79
+ except ValueError:
80
+ location = None
81
+ name = parameter
82
+
83
+ container_name = self._get_parameter_container(location, name)
84
+ result.append(NormalizedParameter(location, name, expression, container_name))
85
+ return result
86
+
87
+ def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
88
+ """Resolve parameter container either from explicit location or by looking up in target operation."""
89
+ if location:
90
+ return LOCATION_TO_CONTAINER[location]
91
+
92
+ for param in self.target.iter_parameters():
93
+ if param.name == name:
94
+ return LOCATION_TO_CONTAINER[param.location]
95
+ raise ValueError(f"Parameter `{name}` is not defined in API operation `{self.target.label}`")
96
+
97
+ def extract(self, output: StepOutput) -> Transition:
98
+ return self._cached_extract(StepOutputWrapper(output))
99
+
100
+ def _extract_impl(self, wrapper: StepOutputWrapper) -> Transition:
101
+ output = wrapper.output
102
+ return Transition(
103
+ id=f"{self.source.label} - {self.status_code} - {self.name}",
104
+ parent_id=output.case.id,
105
+ parameters=self.extract_parameters(output),
106
+ request_body=self.extract_body(output),
107
+ )
108
+
109
+ def extract_parameters(self, output: StepOutput) -> dict[str, dict[str, ExtractedParam]]:
110
+ """Extract parameters using runtime expressions.
111
+
112
+ Returns a two-level dictionary: container -> parameter name -> extracted value
113
+ """
114
+ extracted: dict[str, dict[str, ExtractedParam]] = {}
115
+ for parameter in self.parameters:
116
+ container = extracted.setdefault(parameter.container_name, {})
117
+ value: Result[Any, Exception]
118
+ try:
119
+ value = Ok(expressions.evaluate(parameter.expression, output))
120
+ except Exception as exc:
121
+ value = Err(exc)
122
+ container[parameter.name] = ExtractedParam(definition=parameter.expression, value=value)
123
+ return extracted
124
+
125
+ def extract_body(self, output: StepOutput) -> ExtractedParam | None:
126
+ if not isinstance(self.body, NotSet):
127
+ value: Result[Any, Exception]
128
+ try:
129
+ value = Ok(expressions.evaluate(self.body, output, evaluate_nested=True))
130
+ except Exception as exc:
131
+ value = Err(exc)
132
+ return ExtractedParam(definition=self.body, value=value)
133
+ return None
134
+
135
+
136
+ @dataclass
137
+ class StepOutputWrapper:
138
+ """Wrapper for StepOutput that uses only case_id for hash-based caching."""
139
+
140
+ output: StepOutput
141
+ __slots__ = ("output",)
142
+
143
+ def __hash__(self) -> int:
144
+ return hash(self.output.case.id)
145
+
146
+ def __eq__(self, other: object) -> bool:
147
+ assert isinstance(other, StepOutputWrapper)
148
+ return self.output.case.id == other.output.case.id
149
+
150
+
151
+ def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenApiLink], None, None]:
145
152
  for status_code, definition in operation.definition.raw["responses"].items():
146
153
  definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
147
154
  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)
155
+ yield status_code, OpenApiLink(name, status_code, link_definition, operation)
149
156
 
150
157
 
151
158
  StatusCode = Union[str, int]
@@ -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
@@ -166,15 +166,16 @@ 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
177
  should_skip = self._should_skip
178
+ links_field = self.links_field
178
179
 
179
180
  for path, path_item in paths.items():
180
181
  try:
@@ -183,12 +184,21 @@ class BaseOpenAPISchema(BaseSchema):
183
184
  for method, definition in path_item.items():
184
185
  if method not in HTTP_METHODS:
185
186
  continue
186
- counter.total += 1
187
- if not should_skip(path, method, definition):
188
- counter.selected += 1
187
+ statistic.operations.total += 1
188
+ is_selected = not should_skip(path, method, definition)
189
+ if is_selected:
190
+ statistic.operations.selected += 1
191
+ for response in definition.get("responses", {}).values():
192
+ if "$ref" in response:
193
+ _, response = resolve(response["$ref"])
194
+ defined_links = response.get(links_field)
195
+ if defined_links is not None:
196
+ statistic.links.total += len(defined_links)
197
+ if is_selected:
198
+ statistic.links.selected = len(defined_links)
189
199
  except SCHEMA_PARSING_ERRORS:
190
200
  continue
191
- return counter
201
+ return statistic
192
202
 
193
203
  def _operation_iter(self) -> Generator[dict[str, Any], None, None]:
194
204
  try:
@@ -210,20 +220,6 @@ class BaseOpenAPISchema(BaseSchema):
210
220
  # Ignore errors
211
221
  continue
212
222
 
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
223
  def override(
228
224
  self,
229
225
  *,