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
@@ -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", "code", "case_id", "severity")
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", "code", "case_id", "severity")
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", "code", "case_id", "severity")
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", "code", "case_id", "severity")
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", "code", "case_id", "severity")
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", "code", "case_id", "severity")
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", "code", "case_id", "severity")
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", "code", "case_id", "severity")
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.count_operations().total
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
- def count_operations(self) -> ApiOperationsCount:
229
- """Count total and selected operations."""
230
- if self._operations_count is None:
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 _do_count_operations(self) -> ApiOperationsCount:
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 _do_count_operations(self) -> ApiOperationsCount:
144
- counter = ApiOperationsCount()
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
- counter.total += 1
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
- counter.selected += 1
166
- return counter
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
- if (
395
- # Response indicates a client error, even though all available parameters were taken from links
396
- # and comes from a POST request. This case likely means that the POST request actually did not
397
- # save the resource and it is not available for subsequent operations
398
- 400 <= response.status_code < 500
399
- and parent.operation.method.upper() == "POST"
400
- and 200 <= parent_response.status_code < 400
401
- and overrides_all_parameters
402
- and _is_prefix_operation(
403
- ResourcePath(parent.path, parent.path_parameters or {}),
404
- ResourcePath(case.path, case.path_parameters or {}),
405
- )
406
- ):
407
- created_with = parent.operation.label
408
- not_available_with = case.operation.label
409
- reason = http.client.responses.get(response.status_code, "Unknown")
410
- raise EnsureResourceAvailability(
411
- operation=created_with,
412
- message=(
413
- f"The API returned `{response.status_code} {reason}` for a resource that was just created.\n\n"
414
- f"Created with : `{created_with}`\n"
415
- f"Not available with: `{not_available_with}`"
416
- ),
417
- created_with=created_with,
418
- not_available_with=not_available_with,
419
- )
420
- return None
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, context: ExpressionContext, evaluate_nested: bool = False) -> 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, context)
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(context) for node in parser.parse(expr)]
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, context: ExpressionContext) -> Any:
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, context): evaluate(value, context, evaluate_nested=True)
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, context, evaluate_nested=True) for item in expr]
38
+ return [evaluate(item, output, evaluate_nested=True) for item in expr]
43
39
 
44
40
 
45
- def _evaluate_object_key(key: str, context: ExpressionContext) -> Any:
46
- evaluated = evaluate(key, context)
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, 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