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
schemathesis/openapi/checks.py
CHANGED
@@ -37,7 +37,6 @@ class UndefinedStatusCode(Failure):
|
|
37
37
|
"allowed_status_codes",
|
38
38
|
"message",
|
39
39
|
"title",
|
40
|
-
"code",
|
41
40
|
"case_id",
|
42
41
|
"severity",
|
43
42
|
)
|
@@ -51,7 +50,6 @@ class UndefinedStatusCode(Failure):
|
|
51
50
|
allowed_status_codes: list[int],
|
52
51
|
message: str,
|
53
52
|
title: str = "Undocumented HTTP status code",
|
54
|
-
code: str = "undefined_status_code",
|
55
53
|
case_id: str | None = None,
|
56
54
|
) -> None:
|
57
55
|
self.operation = operation
|
@@ -60,7 +58,6 @@ class UndefinedStatusCode(Failure):
|
|
60
58
|
self.allowed_status_codes = allowed_status_codes
|
61
59
|
self.message = message
|
62
60
|
self.title = title
|
63
|
-
self.code = code
|
64
61
|
self.case_id = case_id
|
65
62
|
self.severity = Severity.MEDIUM
|
66
63
|
|
@@ -72,7 +69,7 @@ class UndefinedStatusCode(Failure):
|
|
72
69
|
class MissingHeaders(Failure):
|
73
70
|
"""Some required headers are missing."""
|
74
71
|
|
75
|
-
__slots__ = ("operation", "missing_headers", "message", "title", "
|
72
|
+
__slots__ = ("operation", "missing_headers", "message", "title", "case_id", "severity")
|
76
73
|
|
77
74
|
def __init__(
|
78
75
|
self,
|
@@ -81,14 +78,12 @@ class MissingHeaders(Failure):
|
|
81
78
|
missing_headers: list[str],
|
82
79
|
message: str,
|
83
80
|
title: str = "Missing required headers",
|
84
|
-
code: str = "missing_headers",
|
85
81
|
case_id: str | None = None,
|
86
82
|
) -> None:
|
87
83
|
self.operation = operation
|
88
84
|
self.missing_headers = missing_headers
|
89
85
|
self.message = message
|
90
86
|
self.title = title
|
91
|
-
self.code = code
|
92
87
|
self.case_id = case_id
|
93
88
|
self.severity = Severity.MEDIUM
|
94
89
|
|
@@ -105,7 +100,6 @@ class JsonSchemaError(Failure):
|
|
105
100
|
"instance",
|
106
101
|
"message",
|
107
102
|
"title",
|
108
|
-
"code",
|
109
103
|
"case_id",
|
110
104
|
"severity",
|
111
105
|
)
|
@@ -121,7 +115,6 @@ class JsonSchemaError(Failure):
|
|
121
115
|
instance: None | bool | float | str | list | dict[str, Any],
|
122
116
|
message: str,
|
123
117
|
title: str = "Response violates schema",
|
124
|
-
code: str = "json_schema",
|
125
118
|
case_id: str | None = None,
|
126
119
|
) -> None:
|
127
120
|
self.operation = operation
|
@@ -132,7 +125,6 @@ class JsonSchemaError(Failure):
|
|
132
125
|
self.instance = instance
|
133
126
|
self.message = message
|
134
127
|
self.title = title
|
135
|
-
self.code = code
|
136
128
|
self.case_id = case_id
|
137
129
|
self.severity = Severity.HIGH
|
138
130
|
|
@@ -176,7 +168,7 @@ class JsonSchemaError(Failure):
|
|
176
168
|
class MissingContentType(Failure):
|
177
169
|
"""Content type header is missing."""
|
178
170
|
|
179
|
-
__slots__ = ("operation", "media_types", "message", "title", "
|
171
|
+
__slots__ = ("operation", "media_types", "message", "title", "case_id", "severity")
|
180
172
|
|
181
173
|
def __init__(
|
182
174
|
self,
|
@@ -185,14 +177,12 @@ class MissingContentType(Failure):
|
|
185
177
|
media_types: list[str],
|
186
178
|
message: str,
|
187
179
|
title: str = "Missing Content-Type header",
|
188
|
-
code: str = "missing_content_type",
|
189
180
|
case_id: str | None = None,
|
190
181
|
) -> None:
|
191
182
|
self.operation = operation
|
192
183
|
self.media_types = media_types
|
193
184
|
self.message = message
|
194
185
|
self.title = title
|
195
|
-
self.code = code
|
196
186
|
self.case_id = case_id
|
197
187
|
self.severity = Severity.MEDIUM
|
198
188
|
|
@@ -204,7 +194,7 @@ class MissingContentType(Failure):
|
|
204
194
|
class MalformedMediaType(Failure):
|
205
195
|
"""Media type name is malformed."""
|
206
196
|
|
207
|
-
__slots__ = ("operation", "actual", "defined", "message", "title", "
|
197
|
+
__slots__ = ("operation", "actual", "defined", "message", "title", "case_id", "severity")
|
208
198
|
|
209
199
|
def __init__(
|
210
200
|
self,
|
@@ -214,7 +204,6 @@ class MalformedMediaType(Failure):
|
|
214
204
|
defined: str,
|
215
205
|
message: str,
|
216
206
|
title: str = "Malformed media type",
|
217
|
-
code: str = "malformed_media_type",
|
218
207
|
case_id: str | None = None,
|
219
208
|
) -> None:
|
220
209
|
self.operation = operation
|
@@ -222,7 +211,6 @@ class MalformedMediaType(Failure):
|
|
222
211
|
self.defined = defined
|
223
212
|
self.message = message
|
224
213
|
self.title = title
|
225
|
-
self.code = code
|
226
214
|
self.case_id = case_id
|
227
215
|
self.severity = Severity.MEDIUM
|
228
216
|
|
@@ -236,7 +224,6 @@ class UndefinedContentType(Failure):
|
|
236
224
|
"defined_content_types",
|
237
225
|
"message",
|
238
226
|
"title",
|
239
|
-
"code",
|
240
227
|
"case_id",
|
241
228
|
"severity",
|
242
229
|
)
|
@@ -249,7 +236,6 @@ class UndefinedContentType(Failure):
|
|
249
236
|
defined_content_types: list[str],
|
250
237
|
message: str,
|
251
238
|
title: str = "Undocumented Content-Type",
|
252
|
-
code: str = "undefined_content_type",
|
253
239
|
case_id: str | None = None,
|
254
240
|
) -> None:
|
255
241
|
self.operation = operation
|
@@ -257,7 +243,6 @@ class UndefinedContentType(Failure):
|
|
257
243
|
self.defined_content_types = defined_content_types
|
258
244
|
self.message = message
|
259
245
|
self.title = title
|
260
|
-
self.code = code
|
261
246
|
self.case_id = case_id
|
262
247
|
self.severity = Severity.MEDIUM
|
263
248
|
|
@@ -269,7 +254,7 @@ class UndefinedContentType(Failure):
|
|
269
254
|
class UseAfterFree(Failure):
|
270
255
|
"""Resource was used after a successful DELETE operation on it."""
|
271
256
|
|
272
|
-
__slots__ = ("operation", "message", "free", "usage", "title", "
|
257
|
+
__slots__ = ("operation", "message", "free", "usage", "title", "case_id", "severity")
|
273
258
|
|
274
259
|
def __init__(
|
275
260
|
self,
|
@@ -279,7 +264,6 @@ class UseAfterFree(Failure):
|
|
279
264
|
free: str,
|
280
265
|
usage: str,
|
281
266
|
title: str = "Use after free",
|
282
|
-
code: str = "use_after_free",
|
283
267
|
case_id: str | None = None,
|
284
268
|
) -> None:
|
285
269
|
self.operation = operation
|
@@ -287,7 +271,6 @@ class UseAfterFree(Failure):
|
|
287
271
|
self.free = free
|
288
272
|
self.usage = usage
|
289
273
|
self.title = title
|
290
|
-
self.code = code
|
291
274
|
self.case_id = case_id
|
292
275
|
self.severity = Severity.CRITICAL
|
293
276
|
|
@@ -299,7 +282,7 @@ class UseAfterFree(Failure):
|
|
299
282
|
class EnsureResourceAvailability(Failure):
|
300
283
|
"""Resource is not available immediately after creation."""
|
301
284
|
|
302
|
-
__slots__ = ("operation", "message", "created_with", "not_available_with", "title", "
|
285
|
+
__slots__ = ("operation", "message", "created_with", "not_available_with", "title", "case_id", "severity")
|
303
286
|
|
304
287
|
def __init__(
|
305
288
|
self,
|
@@ -309,7 +292,6 @@ class EnsureResourceAvailability(Failure):
|
|
309
292
|
created_with: str,
|
310
293
|
not_available_with: str,
|
311
294
|
title: str = "Resource is not available after creation",
|
312
|
-
code: str = "ensure_resource_availability",
|
313
295
|
case_id: str | None = None,
|
314
296
|
) -> None:
|
315
297
|
self.operation = operation
|
@@ -317,7 +299,6 @@ class EnsureResourceAvailability(Failure):
|
|
317
299
|
self.created_with = created_with
|
318
300
|
self.not_available_with = not_available_with
|
319
301
|
self.title = title
|
320
|
-
self.code = code
|
321
302
|
self.case_id = case_id
|
322
303
|
self.severity = Severity.MEDIUM
|
323
304
|
|
@@ -329,7 +310,7 @@ class EnsureResourceAvailability(Failure):
|
|
329
310
|
class IgnoredAuth(Failure):
|
330
311
|
"""The API operation does not check the specified authentication."""
|
331
312
|
|
332
|
-
__slots__ = ("operation", "message", "title", "
|
313
|
+
__slots__ = ("operation", "message", "title", "case_id", "severity")
|
333
314
|
|
334
315
|
def __init__(
|
335
316
|
self,
|
@@ -337,13 +318,11 @@ class IgnoredAuth(Failure):
|
|
337
318
|
operation: str,
|
338
319
|
message: str,
|
339
320
|
title: str = "Authentication declared but not enforced",
|
340
|
-
code: str = "ignored_auth",
|
341
321
|
case_id: str | None = None,
|
342
322
|
) -> None:
|
343
323
|
self.operation = operation
|
344
324
|
self.message = message
|
345
325
|
self.title = title
|
346
|
-
self.code = code
|
347
326
|
self.case_id = case_id
|
348
327
|
self.severity = Severity.CRITICAL
|
349
328
|
|
@@ -355,7 +334,7 @@ class IgnoredAuth(Failure):
|
|
355
334
|
class AcceptedNegativeData(Failure):
|
356
335
|
"""Response with negative data was accepted."""
|
357
336
|
|
358
|
-
__slots__ = ("operation", "message", "status_code", "allowed_statuses", "title", "
|
337
|
+
__slots__ = ("operation", "message", "status_code", "allowed_statuses", "title", "case_id", "severity")
|
359
338
|
|
360
339
|
def __init__(
|
361
340
|
self,
|
@@ -365,7 +344,6 @@ class AcceptedNegativeData(Failure):
|
|
365
344
|
status_code: int,
|
366
345
|
allowed_statuses: list[str],
|
367
346
|
title: str = "Accepted negative data",
|
368
|
-
code: str = "accepted_negative_data",
|
369
347
|
case_id: str | None = None,
|
370
348
|
) -> None:
|
371
349
|
self.operation = operation
|
@@ -373,7 +351,6 @@ class AcceptedNegativeData(Failure):
|
|
373
351
|
self.status_code = status_code
|
374
352
|
self.allowed_statuses = allowed_statuses
|
375
353
|
self.title = title
|
376
|
-
self.code = code
|
377
354
|
self.case_id = case_id
|
378
355
|
self.severity = Severity.MEDIUM
|
379
356
|
|
@@ -385,7 +362,7 @@ class AcceptedNegativeData(Failure):
|
|
385
362
|
class RejectedPositiveData(Failure):
|
386
363
|
"""Response with positive data was rejected."""
|
387
364
|
|
388
|
-
__slots__ = ("operation", "message", "status_code", "allowed_statuses", "title", "
|
365
|
+
__slots__ = ("operation", "message", "status_code", "allowed_statuses", "title", "case_id", "severity")
|
389
366
|
|
390
367
|
def __init__(
|
391
368
|
self,
|
@@ -395,7 +372,6 @@ class RejectedPositiveData(Failure):
|
|
395
372
|
status_code: int,
|
396
373
|
allowed_statuses: list[str],
|
397
374
|
title: str = "Rejected positive data",
|
398
|
-
code: str = "rejected_positive_data",
|
399
375
|
case_id: str | None = None,
|
400
376
|
) -> None:
|
401
377
|
self.operation = operation
|
@@ -403,7 +379,6 @@ class RejectedPositiveData(Failure):
|
|
403
379
|
self.status_code = status_code
|
404
380
|
self.allowed_statuses = allowed_statuses
|
405
381
|
self.title = title
|
406
|
-
self.code = code
|
407
382
|
self.case_id = case_id
|
408
383
|
self.severity = Severity.MEDIUM
|
409
384
|
|
schemathesis/schemas.py
CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from collections.abc import Mapping
|
4
4
|
from dataclasses import dataclass, field
|
5
|
-
from functools import lru_cache, partial
|
5
|
+
from functools import cached_property, lru_cache, partial
|
6
6
|
from itertools import chain
|
7
7
|
from typing import (
|
8
8
|
TYPE_CHECKING,
|
@@ -57,6 +57,34 @@ def get_full_path(base_path: str, path: str) -> str:
|
|
57
57
|
return unquote(urljoin(base_path, quote(path.lstrip("/"))))
|
58
58
|
|
59
59
|
|
60
|
+
@dataclass
|
61
|
+
class FilteredCount:
|
62
|
+
"""Count of total items and those passing filters."""
|
63
|
+
|
64
|
+
total: int
|
65
|
+
selected: int
|
66
|
+
|
67
|
+
__slots__ = ("total", "selected")
|
68
|
+
|
69
|
+
def __init__(self) -> None:
|
70
|
+
self.total = 0
|
71
|
+
self.selected = 0
|
72
|
+
|
73
|
+
|
74
|
+
@dataclass
|
75
|
+
class ApiStatistic:
|
76
|
+
"""Statistics about API operations and links."""
|
77
|
+
|
78
|
+
operations: FilteredCount
|
79
|
+
links: FilteredCount
|
80
|
+
|
81
|
+
__slots__ = ("operations", "links")
|
82
|
+
|
83
|
+
def __init__(self) -> None:
|
84
|
+
self.operations = FilteredCount()
|
85
|
+
self.links = FilteredCount()
|
86
|
+
|
87
|
+
|
60
88
|
@dataclass
|
61
89
|
class ApiOperationsCount:
|
62
90
|
"""Statistics about API operations."""
|
@@ -84,7 +112,6 @@ class BaseSchema(Mapping):
|
|
84
112
|
generation_config: GenerationConfig = field(default_factory=GenerationConfig)
|
85
113
|
output_config: OutputConfig = field(default_factory=OutputConfig)
|
86
114
|
rate_limiter: Limiter | None = None
|
87
|
-
_operations_count: ApiOperationsCount | None = None
|
88
115
|
|
89
116
|
def __post_init__(self) -> None:
|
90
117
|
self.hook = to_filterable_hook(self.hooks) # type: ignore[method-assign]
|
@@ -186,7 +213,7 @@ class BaseSchema(Mapping):
|
|
186
213
|
raise NotImplementedError
|
187
214
|
|
188
215
|
def __len__(self) -> int:
|
189
|
-
return self.
|
216
|
+
return self.statistic.operations.total
|
190
217
|
|
191
218
|
def hook(self, hook: str | Callable) -> Callable:
|
192
219
|
return self.hooks.register(hook)
|
@@ -225,18 +252,11 @@ class BaseSchema(Mapping):
|
|
225
252
|
def validate(self) -> None:
|
226
253
|
raise NotImplementedError
|
227
254
|
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
self._operations_count = self._do_count_operations()
|
232
|
-
return self._operations_count
|
255
|
+
@cached_property
|
256
|
+
def statistic(self) -> ApiStatistic:
|
257
|
+
return self._measure_statistic()
|
233
258
|
|
234
|
-
def
|
235
|
-
"""Implementation-specific counting logic."""
|
236
|
-
raise NotImplementedError
|
237
|
-
|
238
|
-
@property
|
239
|
-
def links_count(self) -> int:
|
259
|
+
def _measure_statistic(self) -> ApiStatistic:
|
240
260
|
raise NotImplementedError
|
241
261
|
|
242
262
|
def get_all_operations(
|
@@ -24,9 +24,11 @@ from hypothesis import strategies as st
|
|
24
24
|
from hypothesis_graphql import strategies as gql_st
|
25
25
|
from requests.structures import CaseInsensitiveDict
|
26
26
|
|
27
|
+
from schemathesis import auths
|
27
28
|
from schemathesis.core import NOT_SET, NotSet, Specification
|
28
29
|
from schemathesis.core.errors import InvalidSchema, OperationNotFound
|
29
30
|
from schemathesis.core.result import Ok, Result
|
31
|
+
from schemathesis.generation import GenerationConfig, GenerationMode
|
30
32
|
from schemathesis.generation.case import Case
|
31
33
|
from schemathesis.generation.meta import (
|
32
34
|
CaseMetadata,
|
@@ -38,12 +40,16 @@ from schemathesis.generation.meta import (
|
|
38
40
|
PhaseInfo,
|
39
41
|
TestPhase,
|
40
42
|
)
|
43
|
+
from schemathesis.hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
44
|
+
from schemathesis.schemas import (
|
45
|
+
APIOperation,
|
46
|
+
APIOperationMap,
|
47
|
+
ApiStatistic,
|
48
|
+
BaseSchema,
|
49
|
+
OperationDefinition,
|
50
|
+
)
|
51
|
+
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
41
52
|
|
42
|
-
from ... import auths
|
43
|
-
from ...generation import GenerationConfig, GenerationMode
|
44
|
-
from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
45
|
-
from ...schemas import APIOperation, APIOperationMap, ApiOperationsCount, BaseSchema, OperationDefinition
|
46
|
-
from ..openapi.constants import LOCATION_TO_CONTAINER
|
47
53
|
from ._cache import OperationCache
|
48
54
|
from .scalars import CUSTOM_SCALARS, get_extra_scalar_strategies
|
49
55
|
|
@@ -140,8 +146,8 @@ class GraphQLSchema(BaseSchema):
|
|
140
146
|
def _get_base_path(self) -> str:
|
141
147
|
return cast(str, urlsplit(self.location).path)
|
142
148
|
|
143
|
-
def
|
144
|
-
|
149
|
+
def _measure_statistic(self) -> ApiStatistic:
|
150
|
+
statistic = ApiStatistic()
|
145
151
|
raw_schema = self.raw_schema["__schema"]
|
146
152
|
dummy_operation = APIOperation(
|
147
153
|
base_url=self.get_base_url(),
|
@@ -159,16 +165,11 @@ class GraphQLSchema(BaseSchema):
|
|
159
165
|
for type_def in raw_schema.get("types", []):
|
160
166
|
if type_def["name"] == query_type_name:
|
161
167
|
for field in type_def["fields"]:
|
162
|
-
|
168
|
+
statistic.operations.total += 1
|
163
169
|
dummy_operation.label = f"{query_type_name}.{field['name']}"
|
164
170
|
if not self._should_skip(dummy_operation):
|
165
|
-
|
166
|
-
return
|
167
|
-
|
168
|
-
@property
|
169
|
-
def links_count(self) -> int:
|
170
|
-
# Links are not supported for GraphQL
|
171
|
-
return 0
|
171
|
+
statistic.operations.selected += 1
|
172
|
+
return statistic
|
172
173
|
|
173
174
|
def get_all_operations(
|
174
175
|
self, generation_config: GenerationConfig | None = None
|
@@ -376,6 +376,10 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
|
|
376
376
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
377
377
|
return True
|
378
378
|
|
379
|
+
# First, check if this is a 4XX response
|
380
|
+
if not (400 <= response.status_code < 500):
|
381
|
+
return None
|
382
|
+
|
379
383
|
parent = ctx.find_parent(case_id=case.id)
|
380
384
|
if parent is None:
|
381
385
|
return None
|
@@ -383,6 +387,17 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
|
|
383
387
|
if parent_response is None:
|
384
388
|
return None
|
385
389
|
|
390
|
+
if not (
|
391
|
+
parent.operation.method.upper() == "POST"
|
392
|
+
and 200 <= parent_response.status_code < 400
|
393
|
+
and _is_prefix_operation(
|
394
|
+
ResourcePath(parent.path, parent.path_parameters or {}),
|
395
|
+
ResourcePath(case.path, case.path_parameters or {}),
|
396
|
+
)
|
397
|
+
):
|
398
|
+
return None
|
399
|
+
|
400
|
+
# Check if all parameters come from links
|
386
401
|
overrides = case._override
|
387
402
|
overrides_all_parameters = True
|
388
403
|
for parameter in case.operation.iter_parameters():
|
@@ -390,34 +405,42 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
|
|
390
405
|
if parameter.name not in getattr(overrides, container, {}):
|
391
406
|
overrides_all_parameters = False
|
392
407
|
break
|
408
|
+
if not overrides_all_parameters:
|
409
|
+
return None
|
393
410
|
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
)
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
411
|
+
# Look for any successful DELETE operations on this resource
|
412
|
+
for related_case in ctx.find_related(case_id=case.id):
|
413
|
+
related_response = ctx.find_response(case_id=related_case.id)
|
414
|
+
if (
|
415
|
+
related_case.operation.method.upper() == "DELETE"
|
416
|
+
and related_response is not None
|
417
|
+
and 200 <= related_response.status_code < 300
|
418
|
+
and _is_prefix_operation(
|
419
|
+
ResourcePath(related_case.path, related_case.path_parameters or {}),
|
420
|
+
ResourcePath(case.path, case.path_parameters or {}),
|
421
|
+
)
|
422
|
+
):
|
423
|
+
# Resource was properly deleted, 404 is expected
|
424
|
+
return None
|
425
|
+
|
426
|
+
# If we got here:
|
427
|
+
# 1. Resource was created successfully
|
428
|
+
# 2. Current operation returned 4XX
|
429
|
+
# 3. All parameters come from links
|
430
|
+
# 4. No successful DELETE operations found
|
431
|
+
created_with = parent.operation.label
|
432
|
+
not_available_with = case.operation.label
|
433
|
+
reason = http.client.responses.get(response.status_code, "Unknown")
|
434
|
+
raise EnsureResourceAvailability(
|
435
|
+
operation=created_with,
|
436
|
+
message=(
|
437
|
+
f"The API returned `{response.status_code} {reason}` for a resource that was just created.\n\n"
|
438
|
+
f"Created with : `{created_with}`\n"
|
439
|
+
f"Not available with: `{not_available_with}`"
|
440
|
+
),
|
441
|
+
created_with=created_with,
|
442
|
+
not_available_with=not_available_with,
|
443
|
+
)
|
421
444
|
|
422
445
|
|
423
446
|
class AuthKind(enum.Enum):
|
@@ -8,42 +8,38 @@ from __future__ import annotations
|
|
8
8
|
import json
|
9
9
|
from typing import Any
|
10
10
|
|
11
|
+
from schemathesis.generation.stateful.state_machine import StepOutput
|
12
|
+
|
11
13
|
from . import lexer, nodes, parser
|
12
|
-
from .context import ExpressionContext
|
13
14
|
|
14
|
-
__all__ = [
|
15
|
-
"lexer",
|
16
|
-
"nodes",
|
17
|
-
"parser",
|
18
|
-
"ExpressionContext",
|
19
|
-
]
|
15
|
+
__all__ = ["lexer", "nodes", "parser"]
|
20
16
|
|
21
17
|
|
22
|
-
def evaluate(expr: Any,
|
18
|
+
def evaluate(expr: Any, output: StepOutput, evaluate_nested: bool = False) -> Any:
|
23
19
|
"""Evaluate runtime expression in context."""
|
24
20
|
if isinstance(expr, (dict, list)) and evaluate_nested:
|
25
|
-
return _evaluate_nested(expr,
|
21
|
+
return _evaluate_nested(expr, output)
|
26
22
|
if not isinstance(expr, str):
|
27
23
|
# Can be a non-string constant
|
28
24
|
return expr
|
29
|
-
parts = [node.evaluate(
|
25
|
+
parts = [node.evaluate(output) for node in parser.parse(expr)]
|
30
26
|
if len(parts) == 1:
|
31
27
|
return parts[0] # keep the return type the same as the internal value type
|
32
28
|
# otherwise, concatenate into a string
|
33
29
|
return "".join(str(part) for part in parts if part is not None)
|
34
30
|
|
35
31
|
|
36
|
-
def _evaluate_nested(expr: dict[str, Any] | list,
|
32
|
+
def _evaluate_nested(expr: dict[str, Any] | list, output: StepOutput) -> Any:
|
37
33
|
if isinstance(expr, dict):
|
38
34
|
return {
|
39
|
-
_evaluate_object_key(key,
|
35
|
+
_evaluate_object_key(key, output): evaluate(value, output, evaluate_nested=True)
|
40
36
|
for key, value in expr.items()
|
41
37
|
}
|
42
|
-
return [evaluate(item,
|
38
|
+
return [evaluate(item, output, evaluate_nested=True) for item in expr]
|
43
39
|
|
44
40
|
|
45
|
-
def _evaluate_object_key(key: str,
|
46
|
-
evaluated = evaluate(key,
|
41
|
+
def _evaluate_object_key(key: str, output: StepOutput) -> Any:
|
42
|
+
evaluated = evaluate(key, output)
|
47
43
|
if isinstance(evaluated, str):
|
48
44
|
return evaluated
|
49
45
|
if isinstance(evaluated, bool):
|
@@ -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,
|
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,
|
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,
|
56
|
+
def evaluate(self, output: StepOutput) -> str:
|
57
57
|
import requests
|
58
58
|
|
59
|
-
base_url =
|
60
|
-
kwargs = REQUESTS_TRANSPORT.serialize_case(
|
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,
|
70
|
-
return
|
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,
|
78
|
-
return str(
|
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,
|
89
|
+
def evaluate(self, output: StepOutput) -> str:
|
90
90
|
container: dict | CaseInsensitiveDict = {
|
91
|
-
"query":
|
92
|
-
"path":
|
93
|
-
"header":
|
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,
|
112
|
-
document =
|
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,
|
129
|
-
value =
|
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,
|
144
|
-
document =
|
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
|