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
@@ -11,6 +11,7 @@ from hypothesis.stateful import RuleBasedStateMachine
11
11
 
12
12
  from schemathesis.checks import CheckFunction
13
13
  from schemathesis.core.errors import IncorrectUsage
14
+ from schemathesis.core.result import Result
14
15
  from schemathesis.core.transport import Response
15
16
  from schemathesis.generation.case import Case
16
17
 
@@ -18,7 +19,7 @@ if TYPE_CHECKING:
18
19
  import hypothesis
19
20
  from requests.structures import CaseInsensitiveDict
20
21
 
21
- from schemathesis.schemas import APIOperation, BaseSchema
22
+ from schemathesis.schemas import BaseSchema
22
23
 
23
24
 
24
25
  NO_LINKS_ERROR_MESSAGE = (
@@ -36,12 +37,51 @@ DEFAULT_STATE_MACHINE_SETTINGS = hypothesis.settings(
36
37
 
37
38
 
38
39
  @dataclass
39
- class StepResult:
40
+ class StepInput:
41
+ """Input for a single state machine step."""
42
+
43
+ case: Case
44
+ transition: Transition | None # None for initial steps
45
+
46
+ __slots__ = ("case", "transition")
47
+
48
+ @classmethod
49
+ def initial(cls, case: Case) -> StepInput:
50
+ return cls(case=case, transition=None)
51
+
52
+
53
+ @dataclass
54
+ class Transition:
55
+ """Data about transition execution."""
56
+
57
+ # ID of the transition (e.g. link name)
58
+ id: str
59
+ parent_id: str
60
+ parameters: dict[str, dict[str, ExtractedParam]]
61
+ request_body: ExtractedParam | None
62
+
63
+ __slots__ = ("id", "parent_id", "parameters", "request_body")
64
+
65
+
66
+ @dataclass
67
+ class ExtractedParam:
68
+ """Result of parameter extraction."""
69
+
70
+ definition: Any
71
+ value: Result[Any, Exception]
72
+
73
+ __slots__ = ("definition", "value")
74
+
75
+
76
+ @dataclass
77
+ class StepOutput:
40
78
  """Output from a single transition of a state machine."""
41
79
 
42
80
  response: Response
43
81
  case: Case
44
82
 
83
+ __slots__ = ("response", "case")
84
+
45
85
 
46
86
  def _normalize_name(name: str) -> str:
47
87
  return re.sub(r"\W|^(?=\d)", "_", name).replace("__", "_")
@@ -89,10 +129,10 @@ class APIStateMachine(RuleBasedStateMachine):
89
129
  target = _normalize_name(target)
90
130
  return super()._new_name(target) # type: ignore
91
131
 
92
- def _get_target_for_result(self, result: StepResult) -> str | None:
132
+ def _get_target_for_result(self, result: StepOutput) -> str | None:
93
133
  raise NotImplementedError
94
134
 
95
- def _add_result_to_targets(self, targets: tuple[str, ...], result: StepResult | None) -> None:
135
+ def _add_result_to_targets(self, targets: tuple[str, ...], result: StepOutput | None) -> None:
96
136
  if result is None:
97
137
  return
98
138
  target = self._get_target_for_result(result)
@@ -115,19 +155,11 @@ class APIStateMachine(RuleBasedStateMachine):
115
155
  # To provide the return type in the rendered documentation
116
156
  teardown.__doc__ = RuleBasedStateMachine.teardown.__doc__
117
157
 
118
- def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
119
- raise NotImplementedError
120
-
121
- def _step(self, case: Case, previous: StepResult | None = None, link: Direction | None = None) -> StepResult | None:
122
- # This method is a proxy that is used under the hood during the state machine initialization.
123
- # The whole point of having it is to make it possible to override `step`; otherwise, custom "step" is ignored.
124
- # It happens because, at the point of initialization, the final class is not yet created.
158
+ def _step(self, input: StepInput) -> StepOutput | None:
125
159
  __tracebackhide__ = True
126
- if previous is not None and link is not None:
127
- return self.step(case, (previous, link))
128
- return self.step(case, None)
160
+ return self.step(input)
129
161
 
130
- def step(self, case: Case, previous: tuple[StepResult, Direction] | None = None) -> StepResult:
162
+ def step(self, input: StepInput) -> StepOutput:
131
163
  """A single state machine step.
132
164
 
133
165
  :param Case case: Generated test case data that should be sent in an API call to the tested API operation.
@@ -137,15 +169,12 @@ class APIStateMachine(RuleBasedStateMachine):
137
169
  It is the most high-level point to extend the testing process. You probably don't need it in most cases.
138
170
  """
139
171
  __tracebackhide__ = True
140
- if previous is not None:
141
- result, direction = previous
142
- case = self.transform(result, direction, case)
143
- self.before_call(case)
144
- kwargs = self.get_call_kwargs(case)
145
- response = self.call(case, **kwargs)
146
- self.after_call(response, case)
147
- self.validate_response(response, case)
148
- return self.store_result(response, case)
172
+ self.before_call(input.case)
173
+ kwargs = self.get_call_kwargs(input.case)
174
+ response = self.call(input.case, **kwargs)
175
+ self.after_call(response, input.case)
176
+ self.validate_response(response, input.case)
177
+ return StepOutput(response, input.case)
149
178
 
150
179
  def before_call(self, case: Case) -> None:
151
180
  """Hook method for modifying the case data before making a request.
@@ -271,15 +300,3 @@ class APIStateMachine(RuleBasedStateMachine):
271
300
  """
272
301
  __tracebackhide__ = True
273
302
  case.validate_response(response, additional_checks=additional_checks)
274
-
275
- def store_result(self, response: Response, case: Case) -> StepResult:
276
- return StepResult(response, case)
277
-
278
-
279
- class Direction:
280
- name: str
281
- status_code: str
282
- operation: APIOperation
283
-
284
- def set_data(self, case: Case, **kwargs: Any) -> None:
285
- raise NotImplementedError
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
11
11
  class UnexpectedGraphQLResponse(Failure):
12
12
  """GraphQL response is not a JSON object."""
13
13
 
14
- __slots__ = ("operation", "type_name", "title", "message", "code", "case_id", "severity")
14
+ __slots__ = ("operation", "type_name", "title", "message", "case_id", "severity")
15
15
 
16
16
  def __init__(
17
17
  self,
@@ -20,14 +20,12 @@ class UnexpectedGraphQLResponse(Failure):
20
20
  type_name: str,
21
21
  title: str = "Unexpected GraphQL Response",
22
22
  message: str,
23
- code: str = "graphql_unexpected_response",
24
23
  case_id: str | None = None,
25
24
  ) -> None:
26
25
  self.operation = operation
27
26
  self.type_name = type_name
28
27
  self.title = title
29
28
  self.message = message
30
- self.code = code
31
29
  self.case_id = case_id
32
30
  self.severity = Severity.MEDIUM
33
31
 
@@ -39,7 +37,7 @@ class UnexpectedGraphQLResponse(Failure):
39
37
  class GraphQLClientError(Failure):
40
38
  """GraphQL query has not been executed."""
41
39
 
42
- __slots__ = ("operation", "errors", "title", "message", "code", "case_id", "_unique_key_cache", "severity")
40
+ __slots__ = ("operation", "errors", "title", "message", "case_id", "_unique_key_cache", "severity")
43
41
 
44
42
  def __init__(
45
43
  self,
@@ -48,14 +46,12 @@ class GraphQLClientError(Failure):
48
46
  message: str,
49
47
  errors: list[GraphQLFormattedError],
50
48
  title: str = "GraphQL client error",
51
- code: str = "graphql_client_error",
52
49
  case_id: str | None = None,
53
50
  ) -> None:
54
51
  self.operation = operation
55
52
  self.errors = errors
56
53
  self.title = title
57
54
  self.message = message
58
- self.code = code
59
55
  self.case_id = case_id
60
56
  self._unique_key_cache: str | None = None
61
57
  self.severity = Severity.MEDIUM
@@ -70,7 +66,7 @@ class GraphQLClientError(Failure):
70
66
  class GraphQLServerError(Failure):
71
67
  """GraphQL response indicates at least one server error."""
72
68
 
73
- __slots__ = ("operation", "errors", "title", "message", "code", "case_id", "_unique_key_cache", "severity")
69
+ __slots__ = ("operation", "errors", "title", "message", "case_id", "_unique_key_cache", "severity")
74
70
 
75
71
  def __init__(
76
72
  self,
@@ -79,14 +75,12 @@ class GraphQLServerError(Failure):
79
75
  message: str,
80
76
  errors: list[GraphQLFormattedError],
81
77
  title: str = "GraphQL server error",
82
- code: str = "graphql_server_error",
83
78
  case_id: str | None = None,
84
79
  ) -> None:
85
80
  self.operation = operation
86
81
  self.errors = errors
87
82
  self.title = title
88
83
  self.message = message
89
- self.code = code
90
84
  self.case_id = case_id
91
85
  self._unique_key_cache: str | None = None
92
86
  self.severity = Severity.CRITICAL
@@ -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
@@ -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):