schemathesis 4.2.1__py3-none-any.whl → 4.3.0__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 (28) hide show
  1. schemathesis/config/__init__.py +8 -1
  2. schemathesis/config/_phases.py +14 -3
  3. schemathesis/config/schema.json +2 -1
  4. schemathesis/core/jsonschema/bundler.py +4 -3
  5. schemathesis/core/jsonschema/references.py +185 -85
  6. schemathesis/core/transforms.py +14 -6
  7. schemathesis/engine/context.py +35 -2
  8. schemathesis/generation/hypothesis/__init__.py +3 -1
  9. schemathesis/specs/openapi/adapter/parameters.py +3 -3
  10. schemathesis/specs/openapi/adapter/protocol.py +2 -0
  11. schemathesis/specs/openapi/adapter/responses.py +29 -7
  12. schemathesis/specs/openapi/adapter/v2.py +2 -0
  13. schemathesis/specs/openapi/adapter/v3_0.py +2 -0
  14. schemathesis/specs/openapi/adapter/v3_1.py +2 -0
  15. schemathesis/specs/openapi/examples.py +92 -50
  16. schemathesis/specs/openapi/stateful/dependencies/__init__.py +88 -0
  17. schemathesis/specs/openapi/stateful/dependencies/inputs.py +182 -0
  18. schemathesis/specs/openapi/stateful/dependencies/models.py +270 -0
  19. schemathesis/specs/openapi/stateful/dependencies/naming.py +168 -0
  20. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  21. schemathesis/specs/openapi/stateful/dependencies/resources.py +270 -0
  22. schemathesis/specs/openapi/stateful/dependencies/schemas.py +343 -0
  23. schemathesis/specs/openapi/stateful/inference.py +2 -1
  24. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/METADATA +1 -1
  25. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/RECORD +28 -21
  26. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/WHEEL +0 -0
  27. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/entry_points.txt +0 -0
  28. {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import enum
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Any, Iterator, Mapping
7
+
8
+ from typing_extensions import TypeAlias
9
+
10
+ from schemathesis.core.parameters import ParameterLocation
11
+ from schemathesis.core.transforms import encode_pointer
12
+
13
+
14
+ @dataclass
15
+ class DependencyGraph:
16
+ """Graph of API operations and their resource dependencies."""
17
+
18
+ operations: OperationMap
19
+ resources: ResourceMap
20
+
21
+ __slots__ = ("operations", "resources")
22
+
23
+ def serialize(self) -> dict[str, Any]:
24
+ serialized = asdict(self)
25
+
26
+ for operation in serialized["operations"].values():
27
+ del operation["method"]
28
+ del operation["path"]
29
+ for input in operation["inputs"]:
30
+ input["resource"] = input["resource"]["name"]
31
+ for output in operation["outputs"]:
32
+ output["resource"] = output["resource"]["name"]
33
+
34
+ for resource in serialized["resources"].values():
35
+ del resource["name"]
36
+ del resource["source"]
37
+
38
+ return serialized
39
+
40
+ def iter_links(self) -> Iterator[ResponseLinks]:
41
+ """Generate OpenAPI Links connecting producer and consumer operations.
42
+
43
+ Creates links from operations that produce resources to operations that
44
+ consume them. For example: `POST /users` (creates `User`) -> `GET /users/{id}`
45
+ (needs `User.id` parameter).
46
+ """
47
+ # Connect each producer output to matching consumer inputs
48
+ for producer in self.operations.values():
49
+ producer_path = encode_pointer(producer.path)
50
+ for output_slot in producer.outputs:
51
+ for consumer in self.operations.values():
52
+ # Skip self-references
53
+ if producer is consumer:
54
+ continue
55
+
56
+ consumer_path = encode_pointer(consumer.path)
57
+ links: dict[str, LinkDefinition] = {}
58
+ for input_slot in consumer.inputs:
59
+ if input_slot.resource is output_slot.resource:
60
+ body_pointer = build_response_body_pointer(
61
+ output_slot.pointer, input_slot.resource_field, output_slot.cardinality
62
+ )
63
+ link_name = f"{consumer.method.capitalize()}{input_slot.resource.name}"
64
+ links[link_name] = LinkDefinition(
65
+ operation_ref=f"#/paths/{consumer_path}/{consumer.method}",
66
+ parameters={
67
+ # Data is extracted from response body
68
+ f"{input_slot.parameter_location.value}.{input_slot.parameter_name}": f"$response.body#{body_pointer}",
69
+ },
70
+ )
71
+ if links:
72
+ yield ResponseLinks(
73
+ producer_operation_ref=f"#/paths/{producer_path}/{producer.method}",
74
+ status_code=output_slot.status_code,
75
+ links=links,
76
+ )
77
+
78
+ def assert_fieldless_resources(self, key: str, known: dict[str, frozenset[str]]) -> None: # pragma: no cover
79
+ """Verify all resources have at least one field."""
80
+ # Fieldless resources usually indicate failed schema extraction, which can be caused by a bug
81
+ known_fieldless = known.get(key, frozenset())
82
+
83
+ for name, resource in self.resources.items():
84
+ if not resource.fields and name not in known_fieldless:
85
+ raise AssertionError(f"Resource {name} has no fields")
86
+
87
+ def assert_incorrect_field_mappings(self, key: str, known: dict[str, frozenset[str]]) -> None:
88
+ """Verify all input slots reference valid fields in their resources."""
89
+ known_mismatches = known.get(key, frozenset())
90
+
91
+ for operation in self.operations.values():
92
+ for input in operation.inputs:
93
+ # Skip unreliable definition sources
94
+ if input.resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
95
+ continue
96
+ resource = self.resources[input.resource.name]
97
+ if (
98
+ input.resource_field not in resource.fields and resource.name not in known_mismatches
99
+ ): # pragma: no cover
100
+ message = (
101
+ f"Operation '{operation.method.upper()} {operation.path}': "
102
+ f"InputSlot references field '{input.resource_field}' "
103
+ f"not found in resource '{resource.name}'"
104
+ )
105
+ matches = difflib.get_close_matches(input.resource_field, resource.fields, n=1, cutoff=0.6)
106
+ if matches:
107
+ message += f". Closest field - `{matches[0]}`"
108
+ elif resource.fields:
109
+ message += f". Available fields - {', '.join(resource.fields)}"
110
+ else:
111
+ message += ". Resource has no fields"
112
+ raise AssertionError(message)
113
+
114
+
115
+ def build_response_body_pointer(pointer: str, field: str, cardinality: Cardinality) -> str:
116
+ if not pointer.endswith("/"):
117
+ pointer += "/"
118
+ if cardinality == Cardinality.MANY:
119
+ # For arrays, reference first element: /data → /data/0
120
+ pointer += "0/"
121
+ pointer += encode_pointer(field)
122
+ return pointer
123
+
124
+
125
+ @dataclass
126
+ class LinkDefinition:
127
+ """OpenAPI Link Object definition.
128
+
129
+ Represents a single link from a producer operation's response to a
130
+ consumer operation's input parameter.
131
+ """
132
+
133
+ operation_ref: str
134
+ """Reference to target operation (e.g., '#/paths/~1users~1{id}/get')"""
135
+
136
+ parameters: dict[str, str]
137
+ """Parameter mappings (e.g., {'path.id': '$response.body#/id'})"""
138
+
139
+ __slots__ = ("operation_ref", "parameters")
140
+
141
+ def to_openapi(self) -> dict[str, Any]:
142
+ """Convert to OpenAPI Links format."""
143
+ return {
144
+ "operationRef": self.operation_ref,
145
+ "parameters": self.parameters,
146
+ }
147
+
148
+
149
+ @dataclass
150
+ class ResponseLinks:
151
+ """Collection of OpenAPI Links for a producer operation's response.
152
+
153
+ Represents all links from a single response (e.g., POST /users -> 201)
154
+ to consumer operations that can use the produced resource.
155
+
156
+ Example:
157
+ POST /users -> 201 might have links to:
158
+ - GET /users/{id}
159
+ - PATCH /users/{id}
160
+ - DELETE /users/{id}
161
+
162
+ """
163
+
164
+ producer_operation_ref: str
165
+ """Reference to producer operation (e.g., '#/paths/~1users/post')"""
166
+
167
+ status_code: str
168
+ """Response status code (e.g., '201', '200', 'default')"""
169
+
170
+ links: dict[str, LinkDefinition]
171
+ """Named links (e.g., {'GetUserById': LinkDefinition(...)})"""
172
+
173
+ __slots__ = ("producer_operation_ref", "status_code", "links")
174
+
175
+ def to_openapi(self) -> dict[str, Any]:
176
+ """Convert to OpenAPI response links format."""
177
+ return {name: link_def.to_openapi() for name, link_def in self.links.items()}
178
+
179
+
180
+ class Cardinality(str, enum.Enum):
181
+ """Whether there is one or many resources in a slot."""
182
+
183
+ ONE = "ONE"
184
+ MANY = "MANY"
185
+
186
+
187
+ @dataclass
188
+ class OperationNode:
189
+ """An API operation with its input/output dependencies."""
190
+
191
+ method: str
192
+ path: str
193
+ # What this operation NEEDS
194
+ inputs: list[InputSlot]
195
+ # What this operation PRODUCES
196
+ outputs: list[OutputSlot]
197
+
198
+ __slots__ = ("method", "path", "inputs", "outputs")
199
+
200
+
201
+ @dataclass
202
+ class InputSlot:
203
+ """A required input for an operation."""
204
+
205
+ # Which resource is needed
206
+ resource: ResourceDefinition
207
+ # Which field from that resource (e.g., "id")
208
+ resource_field: str
209
+ # Where it goes in the request (e.g., "userId")
210
+ parameter_name: str
211
+ parameter_location: ParameterLocation
212
+
213
+ __slots__ = ("resource", "resource_field", "parameter_name", "parameter_location")
214
+
215
+
216
+ @dataclass
217
+ class OutputSlot:
218
+ """Describes how to extract a resource from an operation's response."""
219
+
220
+ # Which resource type
221
+ resource: ResourceDefinition
222
+ # Where in response body (JSON pointer)
223
+ pointer: str
224
+ # Is this a single resource or an array?
225
+ cardinality: Cardinality
226
+ # HTTP status code
227
+ status_code: str
228
+
229
+ __slots__ = ("resource", "pointer", "cardinality", "status_code")
230
+
231
+
232
+ @dataclass
233
+ class ResourceDefinition:
234
+ """A minimal description of a resource structure."""
235
+
236
+ name: str
237
+ # A sorted list of resource fields
238
+ fields: list[str]
239
+ # How this resource was created
240
+ source: DefinitionSource
241
+
242
+ __slots__ = ("name", "fields", "source")
243
+
244
+ @classmethod
245
+ def without_properties(cls, name: str) -> ResourceDefinition:
246
+ return cls(name=name, fields=[], source=DefinitionSource.SCHEMA_WITHOUT_PROPERTIES)
247
+
248
+ @classmethod
249
+ def inferred_from_parameter(cls, name: str, parameter_name: str) -> ResourceDefinition:
250
+ return cls(name=name, fields=[parameter_name], source=DefinitionSource.PARAMETER_INFERENCE)
251
+
252
+
253
+ class DefinitionSource(enum.IntEnum):
254
+ """Quality level of resource information.
255
+
256
+ Lower values are less reliable and should be replaced by higher values.
257
+ Same values should be merged (union of fields).
258
+ """
259
+
260
+ # From spec but no structural information
261
+ SCHEMA_WITHOUT_PROPERTIES = 0
262
+ # Guessed from parameter names (not in spec)
263
+ PARAMETER_INFERENCE = 1
264
+ # From spec with actual field definitions
265
+ SCHEMA_WITH_PROPERTIES = 2
266
+
267
+
268
+ OperationMap: TypeAlias = dict[str, OperationNode]
269
+ ResourceMap: TypeAlias = dict[str, ResourceDefinition]
270
+ CanonicalizationCache: TypeAlias = dict[str, Mapping[str, Any]]
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def from_parameter(parameter: str, path: str) -> str | None:
5
+ # TODO: support other naming patterns
6
+ # Named like "userId" -> look for "User" resource
7
+ if parameter.endswith("Id"):
8
+ return to_pascal_case(parameter[:-2])
9
+ # Named like "user_id" -> look for "User" resource
10
+ elif parameter.endswith("_id"):
11
+ return to_pascal_case(parameter[:-3])
12
+ # Just "id" -> infer from path context
13
+ elif parameter == "id":
14
+ return from_path(path)
15
+ return None
16
+
17
+
18
+ def from_path(path: str) -> str | None:
19
+ segments = [s for s in path.split("/") if s and "{" not in s]
20
+
21
+ if not segments:
22
+ # API Root
23
+ return None
24
+
25
+ singular = to_singular(segments[-1])
26
+ return to_pascal_case(singular)
27
+
28
+
29
+ def to_singular(word: str) -> str:
30
+ if word.endswith("ies"):
31
+ return word[:-3] + "y"
32
+ if word.endswith("sses"):
33
+ return word[:-2]
34
+ if word.endswith(("ses", "xes", "zes", "ches", "shes")):
35
+ return word[:-2]
36
+ if word.endswith("s"):
37
+ return word[:-1]
38
+ return word
39
+
40
+
41
+ def to_plural(word: str) -> str:
42
+ # party -> parties (inverse of ies -> y)
43
+ if word.endswith("y"):
44
+ return word[:-1] + "ies"
45
+ # class -> classes
46
+ if word.endswith("ss"):
47
+ return word + "es"
48
+ # words that normally take -es: box -> boxes
49
+ if word.endswith(("s", "x", "z", "ch", "sh")):
50
+ return word + "es"
51
+ # just add 's' (car -> cars)
52
+ return word + "s"
53
+
54
+
55
+ def to_pascal_case(text: str) -> str:
56
+ parts = text.replace("-", "_").split("_")
57
+ return "".join(word.capitalize() for word in parts if word)
58
+
59
+
60
+ def to_snake_case(text: str) -> str:
61
+ text = text.replace("-", "_")
62
+ # Insert underscores before uppercase letters
63
+ result = []
64
+ for i, char in enumerate(text):
65
+ # Add underscore before uppercase (except at start)
66
+ if i > 0 and char.isupper():
67
+ result.append("_")
68
+ result.append(char.lower())
69
+ return "".join(result)
70
+
71
+
72
+ def find_matching_field(*, parameter: str, resource: str, fields: list[str]) -> str | None:
73
+ """Find which resource field matches the parameter name."""
74
+ if not fields:
75
+ return None
76
+
77
+ # Exact match
78
+ if parameter in fields:
79
+ return parameter
80
+
81
+ # Normalize for fuzzy matching
82
+ parameter_normalized = _normalize_for_matching(parameter)
83
+ resource_normalized = _normalize_for_matching(resource)
84
+
85
+ # Normalized exact match
86
+ # `brandId` -> `Brand.BrandId`
87
+ for field in fields:
88
+ if _normalize_for_matching(field) == parameter_normalized:
89
+ return field
90
+
91
+ # Extract parameter components
92
+ parameter_prefix, param_suffix = _split_parameter_name(parameter)
93
+ parameter_prefix_normalized = _normalize_for_matching(parameter_prefix)
94
+
95
+ # Parameter has resource prefix, field might not
96
+ # Example: `channelId` - `Channel.id`
97
+ if parameter_prefix and parameter_prefix_normalized == resource_normalized:
98
+ suffix_normalized = _normalize_for_matching(param_suffix)
99
+
100
+ for field in fields:
101
+ field_normalized = _normalize_for_matching(field)
102
+ if field_normalized == suffix_normalized:
103
+ return field
104
+
105
+ # Parameter has no prefix, field might have resource prefix
106
+ # Example: `id` - `Channel.channelId`
107
+ if not parameter_prefix and param_suffix:
108
+ expected_field_normalized = resource_normalized + _normalize_for_matching(param_suffix)
109
+
110
+ for field in fields:
111
+ field_normalized = _normalize_for_matching(field)
112
+ if field_normalized == expected_field_normalized:
113
+ return field
114
+
115
+ return None
116
+
117
+
118
+ def _normalize_for_matching(text: str) -> str:
119
+ """Normalize text for case-insensitive, separator-insensitive matching.
120
+
121
+ Examples:
122
+ "channelId" -> "channelid"
123
+ "channel_id" -> "channelid"
124
+ "ChannelId" -> "channelid"
125
+ "Channel" -> "channel"
126
+
127
+ """
128
+ return text.lower().replace("_", "").replace("-", "")
129
+
130
+
131
+ def _split_parameter_name(param_name: str) -> tuple[str, str]:
132
+ """Split parameter into (prefix, suffix) components.
133
+
134
+ Examples:
135
+ "channelId" -> ("channel", "Id")
136
+ "userId" -> ("user", "Id")
137
+ "user_id" -> ("user", "_id")
138
+ "id" -> ("", "id")
139
+ "channel_id" -> ("channel", "_id")
140
+
141
+ """
142
+ if param_name.endswith("Id") and len(param_name) > 2:
143
+ return (param_name[:-2], "Id")
144
+
145
+ if param_name.endswith("_id") and len(param_name) > 3:
146
+ return (param_name[:-3], "_id")
147
+
148
+ return ("", param_name)
149
+
150
+
151
+ def strip_affixes(name: str, prefixes: list[str], suffixes: list[str]) -> str:
152
+ """Remove common prefixes and suffixes from a name (case-insensitive)."""
153
+ result = name.strip()
154
+ name_lower = result.lower()
155
+
156
+ # Remove one matching prefix
157
+ for prefix in prefixes:
158
+ if name_lower.startswith(prefix):
159
+ result = result[len(prefix) :]
160
+ break
161
+
162
+ # Remove one matching suffix
163
+ for suffix in suffixes:
164
+ if name_lower.endswith(suffix):
165
+ result = result[: -len(suffix)]
166
+ break
167
+
168
+ return result.strip()
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Iterator
4
+
5
+ from schemathesis.specs.openapi.stateful.dependencies.models import CanonicalizationCache, OutputSlot, ResourceMap
6
+ from schemathesis.specs.openapi.stateful.dependencies.resources import extract_resources_from_responses
7
+
8
+ if TYPE_CHECKING:
9
+ from schemathesis.core.compat import RefResolver
10
+ from schemathesis.specs.openapi.schemas import APIOperation
11
+
12
+
13
+ def extract_outputs(
14
+ *,
15
+ operation: APIOperation,
16
+ resources: ResourceMap,
17
+ updated_resources: set[str],
18
+ resolver: RefResolver,
19
+ canonicalization_cache: CanonicalizationCache,
20
+ ) -> Iterator[OutputSlot]:
21
+ """Extract resources from API operation's responses."""
22
+ for response, extracted in extract_resources_from_responses(
23
+ operation=operation,
24
+ resources=resources,
25
+ updated_resources=updated_resources,
26
+ resolver=resolver,
27
+ canonicalization_cache=canonicalization_cache,
28
+ ):
29
+ yield OutputSlot(
30
+ resource=extracted.resource,
31
+ pointer=extracted.pointer,
32
+ cardinality=extracted.cardinality,
33
+ status_code=response.status_code,
34
+ )