schemathesis 3.29.2__py3-none-any.whl → 3.30.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 (123) hide show
  1. schemathesis/__init__.py +3 -3
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +1 -3
  4. schemathesis/_hypothesis.py +6 -0
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +1 -0
  7. schemathesis/_rate_limiter.py +2 -1
  8. schemathesis/_xml.py +1 -0
  9. schemathesis/auths.py +4 -2
  10. schemathesis/checks.py +8 -5
  11. schemathesis/cli/__init__.py +8 -1
  12. schemathesis/cli/callbacks.py +3 -4
  13. schemathesis/cli/cassettes.py +6 -4
  14. schemathesis/cli/constants.py +2 -0
  15. schemathesis/cli/context.py +3 -0
  16. schemathesis/cli/debug.py +2 -1
  17. schemathesis/cli/handlers.py +1 -1
  18. schemathesis/cli/options.py +1 -0
  19. schemathesis/cli/output/default.py +50 -22
  20. schemathesis/cli/output/short.py +21 -10
  21. schemathesis/cli/sanitization.py +1 -0
  22. schemathesis/code_samples.py +1 -0
  23. schemathesis/constants.py +1 -0
  24. schemathesis/contrib/openapi/__init__.py +1 -1
  25. schemathesis/contrib/openapi/fill_missing_examples.py +2 -0
  26. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  27. schemathesis/contrib/unique_data.py +2 -1
  28. schemathesis/exceptions.py +40 -26
  29. schemathesis/experimental/__init__.py +14 -0
  30. schemathesis/extra/_aiohttp.py +1 -0
  31. schemathesis/extra/_server.py +1 -0
  32. schemathesis/extra/pytest_plugin.py +13 -24
  33. schemathesis/failures.py +32 -3
  34. schemathesis/filters.py +2 -1
  35. schemathesis/fixups/__init__.py +1 -0
  36. schemathesis/fixups/fast_api.py +2 -2
  37. schemathesis/fixups/utf8_bom.py +1 -2
  38. schemathesis/generation/__init__.py +2 -1
  39. schemathesis/hooks.py +3 -1
  40. schemathesis/internal/copy.py +19 -3
  41. schemathesis/internal/deprecation.py +1 -1
  42. schemathesis/internal/jsonschema.py +2 -1
  43. schemathesis/internal/result.py +1 -1
  44. schemathesis/internal/transformation.py +1 -0
  45. schemathesis/lazy.py +3 -2
  46. schemathesis/loaders.py +4 -2
  47. schemathesis/models.py +20 -5
  48. schemathesis/parameters.py +1 -0
  49. schemathesis/runner/__init__.py +1 -1
  50. schemathesis/runner/events.py +21 -4
  51. schemathesis/runner/impl/core.py +61 -33
  52. schemathesis/runner/impl/solo.py +2 -1
  53. schemathesis/runner/impl/threadpool.py +4 -0
  54. schemathesis/runner/probes.py +1 -1
  55. schemathesis/runner/serialization.py +1 -1
  56. schemathesis/sanitization.py +2 -0
  57. schemathesis/schemas.py +1 -4
  58. schemathesis/service/ci.py +1 -0
  59. schemathesis/service/client.py +7 -7
  60. schemathesis/service/events.py +2 -1
  61. schemathesis/service/extensions.py +5 -5
  62. schemathesis/service/hosts.py +1 -0
  63. schemathesis/service/metadata.py +2 -1
  64. schemathesis/service/models.py +2 -1
  65. schemathesis/service/report.py +3 -3
  66. schemathesis/service/serialization.py +54 -23
  67. schemathesis/service/usage.py +1 -0
  68. schemathesis/specs/graphql/_cache.py +1 -1
  69. schemathesis/specs/graphql/loaders.py +1 -1
  70. schemathesis/specs/graphql/nodes.py +1 -0
  71. schemathesis/specs/graphql/scalars.py +2 -2
  72. schemathesis/specs/graphql/schemas.py +7 -7
  73. schemathesis/specs/graphql/validation.py +1 -2
  74. schemathesis/specs/openapi/_hypothesis.py +17 -11
  75. schemathesis/specs/openapi/checks.py +102 -9
  76. schemathesis/specs/openapi/converter.py +2 -1
  77. schemathesis/specs/openapi/definitions.py +2 -1
  78. schemathesis/specs/openapi/examples.py +7 -9
  79. schemathesis/specs/openapi/expressions/__init__.py +29 -2
  80. schemathesis/specs/openapi/expressions/context.py +1 -1
  81. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  82. schemathesis/specs/openapi/expressions/lexer.py +19 -18
  83. schemathesis/specs/openapi/expressions/nodes.py +24 -4
  84. schemathesis/specs/openapi/expressions/parser.py +26 -5
  85. schemathesis/specs/openapi/filters.py +1 -0
  86. schemathesis/specs/openapi/links.py +35 -7
  87. schemathesis/specs/openapi/loaders.py +13 -11
  88. schemathesis/specs/openapi/negative/__init__.py +2 -1
  89. schemathesis/specs/openapi/negative/mutations.py +1 -0
  90. schemathesis/specs/openapi/parameters.py +1 -0
  91. schemathesis/specs/openapi/schemas.py +27 -38
  92. schemathesis/specs/openapi/security.py +1 -0
  93. schemathesis/specs/openapi/serialization.py +1 -0
  94. schemathesis/specs/openapi/stateful/__init__.py +159 -70
  95. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  96. schemathesis/specs/openapi/stateful/types.py +13 -0
  97. schemathesis/specs/openapi/utils.py +1 -0
  98. schemathesis/specs/openapi/validation.py +1 -0
  99. schemathesis/stateful/__init__.py +4 -2
  100. schemathesis/stateful/config.py +66 -0
  101. schemathesis/stateful/context.py +93 -0
  102. schemathesis/stateful/events.py +209 -0
  103. schemathesis/stateful/runner.py +233 -0
  104. schemathesis/stateful/sink.py +68 -0
  105. schemathesis/stateful/state_machine.py +39 -22
  106. schemathesis/stateful/statistic.py +20 -0
  107. schemathesis/stateful/validation.py +66 -0
  108. schemathesis/targets.py +1 -0
  109. schemathesis/throttling.py +23 -3
  110. schemathesis/transports/__init__.py +28 -10
  111. schemathesis/transports/auth.py +1 -0
  112. schemathesis/transports/content_types.py +1 -1
  113. schemathesis/transports/headers.py +2 -1
  114. schemathesis/transports/responses.py +6 -4
  115. schemathesis/types.py +1 -0
  116. schemathesis/utils.py +1 -0
  117. {schemathesis-3.29.2.dist-info → schemathesis-3.30.0.dist-info}/METADATA +1 -1
  118. schemathesis-3.30.0.dist-info/RECORD +150 -0
  119. schemathesis/specs/openapi/stateful/links.py +0 -92
  120. schemathesis-3.29.2.dist-info/RECORD +0 -141
  121. {schemathesis-3.29.2.dist-info → schemathesis-3.30.0.dist-info}/WHEEL +0 -0
  122. {schemathesis-3.29.2.dist-info → schemathesis-3.30.0.dist-info}/entry_points.txt +0 -0
  123. {schemathesis-3.29.2.dist-info → schemathesis-3.30.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  from dataclasses import dataclass, field
9
9
  from difflib import get_close_matches
10
- from typing import TYPE_CHECKING, Any, Generator, NoReturn, Sequence, Union
10
+ from typing import TYPE_CHECKING, Any, Generator, NoReturn, Sequence, TypedDict, Union
11
11
 
12
12
  from jsonschema import RefResolver
13
13
 
@@ -21,7 +21,7 @@ from ...types import NotSet
21
21
  from . import expressions
22
22
  from .constants import LOCATION_TO_CONTAINER
23
23
  from .parameters import OpenAPI20Body, OpenAPI30Body, OpenAPIParameter
24
- from .references import Unresolvable, RECURSION_DEPTH_LIMIT
24
+ from .references import RECURSION_DEPTH_LIMIT, Unresolvable
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from ...transports.responses import GenericResponse
@@ -32,6 +32,7 @@ class Link(StatefulTest):
32
32
  operation: APIOperation
33
33
  parameters: dict[str, Any]
34
34
  request_body: Any = NOT_SET
35
+ merge_body: bool = True
35
36
 
36
37
  def __post_init__(self) -> None:
37
38
  if self.request_body is not NOT_SET and not self.operation.body:
@@ -51,6 +52,7 @@ class Link(StatefulTest):
51
52
  operation = source_operation.schema.get_operation_by_id(definition["operationId"]) # type: ignore
52
53
  else:
53
54
  operation = source_operation.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
55
+ extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
54
56
  return cls(
55
57
  # Pylint can't detect that the API operation is always defined at this point
56
58
  # E.g. if there is no matching operation or no operations at all, then a ValueError will be risen
@@ -58,6 +60,7 @@ class Link(StatefulTest):
58
60
  operation=operation,
59
61
  parameters=definition.get("parameters", {}),
60
62
  request_body=definition.get("requestBody", NOT_SET), # `None` might be a valid value - `null`
63
+ merge_body=extension.get("merge_body", True) if extension is not None else True,
61
64
  )
62
65
 
63
66
  def parse(self, case: Case, response: GenericResponse) -> ParsedData:
@@ -69,10 +72,9 @@ class Link(StatefulTest):
69
72
  if isinstance(evaluated, Unresolvable):
70
73
  raise UnresolvableLink(f"Unresolvable reference in the link: {expression}")
71
74
  parameters[parameter] = evaluated
72
- # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#link-object
73
- # > A literal value or {expression} to use as a request body when calling the target operation.
74
- # In this case all literals will be passed as is, and expressions will be evaluated
75
- body = expressions.evaluate(self.request_body, context)
75
+ body = expressions.evaluate(self.request_body, context, evaluate_nested=True)
76
+ if self.merge_body:
77
+ body = merge_body(case.body, body)
76
78
  return ParsedData(parameters=parameters, body=body)
77
79
 
78
80
  def make_operation(self, collected: list[ParsedData]) -> APIOperation:
@@ -170,6 +172,13 @@ def get_links(response: GenericResponse, operation: APIOperation, field: str) ->
170
172
  return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
171
173
 
172
174
 
175
+ SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
176
+
177
+
178
+ class SchemathesisLink(TypedDict):
179
+ merge_body: bool
180
+
181
+
173
182
  @dataclass(repr=False)
174
183
  class OpenAPILink(Direction):
175
184
  """Alternative approach to link processing.
@@ -183,13 +192,22 @@ class OpenAPILink(Direction):
183
192
  operation: APIOperation
184
193
  parameters: list[tuple[str | None, str, str]] = field(init=False)
185
194
  body: dict[str, Any] | NotSet = field(init=False)
195
+ merge_body: bool = True
196
+
197
+ def __repr__(self) -> str:
198
+ path = self.operation.path
199
+ method = self.operation.method
200
+ return f"state.schema['{path}']['{method}'].links['{self.status_code}']['{self.name}']"
186
201
 
187
202
  def __post_init__(self) -> None:
203
+ extension = self.definition.get(SCHEMATHESIS_LINK_EXTENSION)
188
204
  self.parameters = [
189
205
  normalize_parameter(parameter, expression)
190
206
  for parameter, expression in self.definition.get("parameters", {}).items()
191
207
  ]
192
208
  self.body = self.definition.get("requestBody", NOT_SET)
209
+ if extension is not None:
210
+ self.merge_body = extension.get("merge_body", True)
193
211
 
194
212
  def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
195
213
  """Assign all linked definitions to the new case instance."""
@@ -215,7 +233,11 @@ class OpenAPILink(Direction):
215
233
 
216
234
  def set_body(self, case: Case, context: expressions.ExpressionContext) -> None:
217
235
  if self.body is not NOT_SET:
218
- case.body = expressions.evaluate(self.body, context)
236
+ evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
237
+ if self.merge_body:
238
+ case.body = merge_body(case.body, evaluated)
239
+ else:
240
+ case.body = evaluated
219
241
 
220
242
  def get_target_operation(self) -> APIOperation:
221
243
  if "operationId" in self.definition:
@@ -223,6 +245,12 @@ class OpenAPILink(Direction):
223
245
  return self.operation.schema.get_operation_by_reference(self.definition["operationRef"]) # type: ignore
224
246
 
225
247
 
248
+ def merge_body(old: Any, new: Any) -> Any:
249
+ if isinstance(old, dict) and isinstance(new, dict):
250
+ return {**old, **new}
251
+ return new
252
+
253
+
226
254
  def get_container(case: Case, location: str | None, name: str) -> dict[str, Any] | None:
227
255
  """Get a container that suppose to store the given parameter."""
228
256
  if location:
@@ -1,37 +1,38 @@
1
1
  from __future__ import annotations
2
+
2
3
  import io
3
4
  import json
4
5
  import pathlib
5
6
  import re
6
- from typing import IO, Any, Callable, cast, TYPE_CHECKING
7
+ from typing import IO, TYPE_CHECKING, Any, Callable, cast
7
8
  from urllib.parse import urljoin
8
9
 
9
10
  from ... import experimental, fixups
10
11
  from ...code_samples import CodeSampleStyle
12
+ from ...constants import NOT_SET, WAIT_FOR_SCHEMA_INTERVAL
13
+ from ...exceptions import SchemaError, SchemaErrorType
11
14
  from ...generation import (
12
15
  DEFAULT_DATA_GENERATION_METHODS,
13
- DataGenerationMethodInput,
14
16
  DataGenerationMethod,
17
+ DataGenerationMethodInput,
15
18
  GenerationConfig,
16
19
  )
17
- from ...constants import WAIT_FOR_SCHEMA_INTERVAL
18
- from ...exceptions import SchemaError, SchemaErrorType
19
20
  from ...hooks import HookContext, dispatch
21
+ from ...internal.validation import require_relative_url
20
22
  from ...loaders import load_schema_from_url, load_yaml
21
23
  from ...throttling import build_limiter
22
- from ...types import Filter, NotSet, PathLike
23
24
  from ...transports.content_types import is_json_media_type, is_yaml_media_type
24
25
  from ...transports.headers import setup_default_headers
25
- from ...internal.validation import require_relative_url
26
- from ...constants import NOT_SET
26
+ from ...types import Filter, NotSet, PathLike
27
27
  from . import definitions, validation
28
28
 
29
29
  if TYPE_CHECKING:
30
- from .schemas import BaseOpenAPISchema
31
- from ...transports.responses import GenericResponse
32
30
  import jsonschema
33
31
  from pyrate_limiter import Limiter
32
+
34
33
  from ...lazy import LazySchema
34
+ from ...transports.responses import GenericResponse
35
+ from .schemas import BaseOpenAPISchema
35
36
 
36
37
 
37
38
  def _is_json_response(response: GenericResponse) -> bool:
@@ -303,8 +304,8 @@ def from_dict(
303
304
 
304
305
  :param dict raw_schema: A schema to load.
305
306
  """
306
- from .schemas import OpenApi30, SwaggerV20
307
307
  from ... import transports
308
+ from .schemas import OpenApi30, SwaggerV20
308
309
 
309
310
  if not isinstance(raw_schema, dict):
310
311
  raise SchemaError(SchemaErrorType.OPEN_API_INVALID_SCHEMA, SCHEMA_INVALID_ERROR)
@@ -533,9 +534,10 @@ def from_wsgi(
533
534
  :param str schema_path: An in-app relative URL to the schema.
534
535
  :param app: A WSGI app instance.
535
536
  """
536
- from ...transports.responses import WSGIResponse
537
537
  from werkzeug.test import Client
538
538
 
539
+ from ...transports.responses import WSGIResponse
540
+
539
541
  require_relative_url(schema_path)
540
542
  setup_default_headers(kwargs)
541
543
  client = Client(app, WSGIResponse)
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  from dataclasses import dataclass
3
4
  from functools import lru_cache
4
5
  from typing import Any
@@ -8,10 +9,10 @@ import jsonschema
8
9
  from hypothesis import strategies as st
9
10
  from hypothesis_jsonschema import from_schema
10
11
 
12
+ from ....generation import GenerationConfig
11
13
  from ..constants import ALL_KEYWORDS
12
14
  from .mutations import MutationContext
13
15
  from .types import Draw, Schema
14
- from ....generation import GenerationConfig
15
16
 
16
17
 
17
18
  @dataclass
@@ -1,6 +1,7 @@
1
1
  """Schema mutations."""
2
2
 
3
3
  from __future__ import annotations
4
+
4
5
  import enum
5
6
  from dataclasses import dataclass
6
7
  from functools import wraps
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  import json
3
4
  from dataclasses import dataclass
4
5
  from typing import Any, ClassVar, Iterable
@@ -40,7 +40,6 @@ from ...exceptions import (
40
40
  OperationSchemaError,
41
41
  SchemaError,
42
42
  SchemaErrorType,
43
- UsageError,
44
43
  get_missing_content_type_error,
45
44
  get_response_parsing_error,
46
45
  get_schema_validation_error,
@@ -286,27 +285,28 @@ class BaseOpenAPISchema(BaseSchema):
286
285
  continue
287
286
  dispatch_hook("before_process_path", context, path, path_item)
288
287
  scope, path_item = resolve_path_item(path_item)
289
- shared_parameters = resolve_shared_parameters(path_item)
290
- for method, entry in path_item.items():
291
- if method not in HTTP_METHODS:
292
- continue
293
- try:
294
- resolved = resolve_operation(entry)
295
- if should_skip(method, resolved):
296
- continue
297
- parameters = resolved.get("parameters", ())
298
- parameters = collect_parameters(itertools.chain(parameters, shared_parameters), resolved)
299
- operation = make_operation(path, method, parameters, entry, resolved, scope)
300
- context = HookContext(operation=operation)
301
- if (
302
- should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
303
- or should_skip_operation(hooks, context)
304
- or (hooks and should_skip_operation(hooks, context))
305
- ):
288
+ with in_scope(self.resolver, scope):
289
+ shared_parameters = resolve_shared_parameters(path_item)
290
+ for method, entry in path_item.items():
291
+ if method not in HTTP_METHODS:
306
292
  continue
307
- yield Ok(operation)
308
- except SCHEMA_PARSING_ERRORS as exc:
309
- yield self._into_err(exc, path, method)
293
+ try:
294
+ resolved = resolve_operation(entry)
295
+ if should_skip(method, resolved):
296
+ continue
297
+ parameters = resolved.get("parameters", ())
298
+ parameters = collect_parameters(itertools.chain(parameters, shared_parameters), resolved)
299
+ operation = make_operation(path, method, parameters, entry, resolved, scope)
300
+ context = HookContext(operation=operation)
301
+ if (
302
+ should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
303
+ or should_skip_operation(hooks, context)
304
+ or (hooks and should_skip_operation(hooks, context))
305
+ ):
306
+ continue
307
+ yield Ok(operation)
308
+ except SCHEMA_PARSING_ERRORS as exc:
309
+ yield self._into_err(exc, path, method)
310
310
  except SCHEMA_PARSING_ERRORS as exc:
311
311
  yield self._into_err(exc, path, method)
312
312
 
@@ -626,7 +626,7 @@ class BaseOpenAPISchema(BaseSchema):
626
626
  formatted_content_types = [f"\n- `{content_type}`" for content_type in media_types]
627
627
  message = f"The following media types are documented in the schema:{''.join(formatted_content_types)}"
628
628
  try:
629
- raise get_missing_content_type_error()(
629
+ raise get_missing_content_type_error(operation.verbose_name)(
630
630
  failures.MissingContentType.title,
631
631
  context=failures.MissingContentType(message=message, media_types=media_types),
632
632
  )
@@ -638,7 +638,7 @@ class BaseOpenAPISchema(BaseSchema):
638
638
  try:
639
639
  data = get_json(response)
640
640
  except JSONDecodeError as exc:
641
- exc_class = get_response_parsing_error(exc)
641
+ exc_class = get_response_parsing_error(operation.verbose_name, exc)
642
642
  context = failures.JSONDecodeErrorContext.from_exception(exc)
643
643
  try:
644
644
  raise exc_class(context.title, context=context) from exc
@@ -656,7 +656,7 @@ class BaseOpenAPISchema(BaseSchema):
656
656
  try:
657
657
  jsonschema.validate(data, schema, cls=cls, resolver=resolver)
658
658
  except jsonschema.ValidationError as exc:
659
- exc_class = get_schema_validation_error(exc)
659
+ exc_class = get_schema_validation_error(operation.verbose_name, exc)
660
660
  ctx = failures.ValidationErrorContext.from_exception(exc)
661
661
  try:
662
662
  raise exc_class(ctx.title, context=ctx) from exc
@@ -926,7 +926,7 @@ class SwaggerV20(BaseOpenAPISchema):
926
926
 
927
927
  def get_strategies_from_examples(self, operation: APIOperation) -> list[SearchStrategy[Case]]:
928
928
  """Get examples from the API operation."""
929
- return get_strategies_from_examples(operation, self.examples_field)
929
+ return get_strategies_from_examples(operation)
930
930
 
931
931
  def get_response_schema(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any] | None]:
932
932
  scopes, definition = self.resolver.resolve_in_scope(definition, scope)
@@ -998,18 +998,7 @@ class SwaggerV20(BaseOpenAPISchema):
998
998
  media_type: str | None = None,
999
999
  ) -> C:
1000
1000
  if body is not NOT_SET and media_type is None:
1001
- # If the user wants to send payload, then there should be a media type, otherwise the payload is ignored
1002
- media_types = operation.get_request_payload_content_types()
1003
- if len(media_types) == 1:
1004
- # The only available option
1005
- media_type = media_types[0]
1006
- else:
1007
- media_types_repr = ", ".join(media_types)
1008
- raise UsageError(
1009
- "Can not detect appropriate media type. "
1010
- "You can either specify one of the defined media types "
1011
- f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
1012
- )
1001
+ media_type = operation._get_default_media_type()
1013
1002
  return case_cls(
1014
1003
  operation=operation,
1015
1004
  path_parameters=path_parameters,
@@ -1101,7 +1090,7 @@ class OpenApi30(SwaggerV20):
1101
1090
 
1102
1091
  def get_strategies_from_examples(self, operation: APIOperation) -> list[SearchStrategy[Case]]:
1103
1092
  """Get examples from the API operation."""
1104
- return get_strategies_from_examples(operation, self.examples_field)
1093
+ return get_strategies_from_examples(operation)
1105
1094
 
1106
1095
  def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
1107
1096
  definitions = self._get_response_definitions(operation, response)
@@ -1,6 +1,7 @@
1
1
  """Processing of ``securityDefinitions`` or ``securitySchemes`` keywords."""
2
2
 
3
3
  from __future__ import annotations
4
+
4
5
  from dataclasses import dataclass
5
6
  from typing import Any, ClassVar, Generator
6
7
 
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  import json
3
4
  from typing import Any, Callable, Dict, Generator, List
4
5
 
@@ -1,28 +1,46 @@
1
1
  from __future__ import annotations
2
+
2
3
  from collections import defaultdict
3
- from typing import TYPE_CHECKING, Any, List, cast
4
+ from functools import lru_cache
5
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator
4
6
 
5
7
  from hypothesis import strategies as st
6
- from hypothesis.stateful import Bundle, Rule, precondition, rule
7
- from requests.structures import CaseInsensitiveDict
8
+ from hypothesis.stateful import Bundle, Rule, rule
8
9
 
10
+ from ....constants import NOT_SET
9
11
  from ....internal.result import Ok
10
12
  from ....stateful.state_machine import APIStateMachine, Direction, StepResult
11
- from ....utils import combine_strategies
13
+ from ....types import NotSet
12
14
  from .. import expressions
13
- from .links import APIOperationConnections, Connection, apply
15
+ from ..links import get_all_links
16
+ from ..utils import expand_status_code
17
+ from .statistic import OpenAPILinkStats
18
+ from .types import FilterFunction, LinkName, StatusCode, TargetName
14
19
 
15
20
  if TYPE_CHECKING:
16
- from ....models import APIOperation, Case
21
+ from ....models import Case
17
22
  from ..schemas import BaseOpenAPISchema
18
23
 
19
24
 
20
25
  class OpenAPIStateMachine(APIStateMachine):
26
+ _transition_stats_template: ClassVar[OpenAPILinkStats]
27
+ _response_matchers: dict[str, Callable[[StepResult], str | None]]
28
+
29
+ def _get_target_for_result(self, result: StepResult) -> str | None:
30
+ matcher = self._response_matchers.get(result.case.operation.verbose_name)
31
+ if matcher is None:
32
+ return None
33
+ return matcher(result)
34
+
21
35
  def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
22
36
  context = expressions.ExpressionContext(case=result.case, response=result.response)
23
37
  direction.set_data(case, elapsed=result.elapsed, context=context)
24
38
  return case
25
39
 
40
+ @classmethod
41
+ def format_rules(cls) -> str:
42
+ return "\n".join(item.line for item in cls._transition_stats_template.iter_with_format())
43
+
26
44
 
27
45
  def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
28
46
  """Create a state machine class.
@@ -33,75 +51,146 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
33
51
 
34
52
  This state machine won't make calls to (2) without having a proper response from (1) first.
35
53
  """
36
- # Bundles are special strategies, allowing us to draw responses from previous calls
37
- bundles = init_bundles(schema)
38
- connections: APIOperationConnections = defaultdict(list)
54
+ from ....stateful.state_machine import _normalize_name
55
+
39
56
  operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
57
+ bundles = {}
58
+ incoming_transitions = defaultdict(list)
59
+ _response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
60
+ # Statistic structure follows the links and count for each response status code
61
+ transitions = {}
40
62
  for operation in operations:
41
- apply(operation, bundles, connections)
63
+ operation_links: dict[StatusCode, dict[TargetName, dict[LinkName, dict[int | None, int]]]] = {}
64
+ all_status_codes = tuple(operation.definition.raw["responses"])
65
+ bundle_matchers = []
66
+ for _, link in get_all_links(operation):
67
+ bundle_name = f"{operation.verbose_name} -> {link.status_code}"
68
+ bundles[bundle_name] = Bundle(bundle_name)
69
+ target_operation = link.get_target_operation()
70
+ incoming_transitions[target_operation.verbose_name].append(link)
71
+ response_targets = operation_links.setdefault(link.status_code, {})
72
+ target_links = response_targets.setdefault(target_operation.verbose_name, {})
73
+ target_links[link.name] = {}
74
+ bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
75
+ if operation_links:
76
+ transitions[operation.verbose_name] = operation_links
77
+ if bundle_matchers:
78
+ _response_matchers[operation.verbose_name] = make_response_matcher(bundle_matchers)
79
+ rules = {}
80
+ catch_all = Bundle("catch_all")
81
+
82
+ for target in operations:
83
+ incoming = incoming_transitions.get(target.verbose_name)
84
+ if incoming is not None:
85
+ for link in incoming:
86
+ source = link.operation
87
+ bundle_name = f"{source.verbose_name} -> {link.status_code}"
88
+ name = _normalize_name(f"{target.verbose_name} -> {link.status_code}")
89
+ rules[name] = transition(
90
+ name=name,
91
+ target=catch_all,
92
+ previous=bundles[bundle_name],
93
+ case=target.as_strategy(),
94
+ link=st.just(link),
95
+ )
96
+ elif any(
97
+ incoming.operation.verbose_name == target.verbose_name
98
+ for transitions in incoming_transitions.values()
99
+ for incoming in transitions
100
+ ):
101
+ # No incoming transitions, but has at least one outgoing transition
102
+ # For example, POST /users/ -> GET /users/{id}/
103
+ # The source operation has no prerequisite, but we need to allow this rule to be executed
104
+ # in order to reach other transitions
105
+ name = _normalize_name(f"{target.verbose_name} -> X")
106
+ rules[name] = transition(
107
+ name=name,
108
+ target=catch_all,
109
+ previous=st.none(),
110
+ case=target.as_strategy(),
111
+ )
112
+
113
+ return type(
114
+ "APIWorkflow",
115
+ (OpenAPIStateMachine,),
116
+ {
117
+ "schema": schema,
118
+ "bundles": bundles,
119
+ "_transition_stats_template": OpenAPILinkStats(transitions=transitions),
120
+ "_response_matchers": _response_matchers,
121
+ **rules,
122
+ },
123
+ )
124
+
125
+
126
+ def transition(
127
+ *,
128
+ name: str,
129
+ target: Bundle,
130
+ previous: Bundle | st.SearchStrategy,
131
+ case: st.SearchStrategy,
132
+ link: st.SearchStrategy | NotSet = NOT_SET,
133
+ ) -> Callable[[Callable], Rule]:
134
+ def step_function(*args_: Any, **kwargs_: Any) -> StepResult:
135
+ return APIStateMachine._step(*args_, **kwargs_)
136
+
137
+ step_function.__name__ = name
138
+
139
+ kwargs = {"target": target, "previous": previous, "case": case}
140
+ if not isinstance(link, NotSet):
141
+ kwargs["link"] = link
142
+
143
+ return rule(**kwargs)(step_function)
144
+
145
+
146
+ def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepResult], str | None]:
147
+ def compare(result: StepResult) -> str | None:
148
+ for bundle_name, response_filter in matchers:
149
+ if response_filter(result):
150
+ return bundle_name
151
+ return None
152
+
153
+ return compare
154
+
155
+
156
+ @lru_cache()
157
+ def make_response_filter(status_code: str, all_status_codes: Iterator[str]) -> FilterFunction:
158
+ """Create a filter for stored responses.
159
+
160
+ This filter will decide whether some response is suitable to use as a source for requesting some API operation.
161
+ """
162
+ if status_code == "default":
163
+ return default_status_code(all_status_codes)
164
+ return match_status_code(status_code)
165
+
42
166
 
43
- rules = make_all_rules(operations, bundles, connections)
167
+ def match_status_code(status_code: str) -> FilterFunction:
168
+ """Create a filter function that matches all responses with the given status code.
44
169
 
45
- kwargs: dict[str, Any] = {"bundles": bundles, "schema": schema}
46
- return type("APIWorkflow", (OpenAPIStateMachine,), {**kwargs, **rules})
170
+ Note that the status code can contain "X", which means any digit.
171
+ For example, 50X will match all status codes from 500 to 509.
172
+ """
173
+ status_codes = set(expand_status_code(status_code))
174
+
175
+ def compare(result: StepResult) -> bool:
176
+ return result.response.status_code in status_codes
177
+
178
+ compare.__name__ = f"match_{status_code}_response"
179
+
180
+ return compare
47
181
 
48
182
 
49
- def init_bundles(schema: BaseOpenAPISchema) -> dict[str, CaseInsensitiveDict]:
50
- """Create bundles for all operations in the given schema.
183
+ def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
184
+ """Create a filter that matches all "default" responses.
51
185
 
52
- Each API operation has a bundle that stores all responses from that operation.
53
- We need to create bundles first, so they can be referred when building connections between operations.
186
+ In Open API, the "default" response is the one that is used if no other options were matched.
187
+ Therefore, we need to match only responses that were not matched by other listed status codes.
54
188
  """
55
- output: dict[str, CaseInsensitiveDict] = {}
56
- for result in schema.get_all_operations():
57
- if isinstance(result, Ok):
58
- operation = result.ok()
59
- output.setdefault(operation.path, CaseInsensitiveDict())
60
- output[operation.path][operation.method.upper()] = Bundle(operation.verbose_name) # type: ignore
61
- return output
62
-
63
-
64
- def make_all_rules(
65
- operations: list[APIOperation],
66
- bundles: dict[str, CaseInsensitiveDict],
67
- connections: APIOperationConnections,
68
- ) -> dict[str, Rule]:
69
- """Create rules for all API operations, based on the provided connections."""
70
- rules = {}
71
- for operation in operations:
72
- new_rule = make_rule(operation, bundles[operation.path][operation.method.upper()], connections)
73
- if new_rule is not None:
74
- rules[f"rule {operation.verbose_name}"] = new_rule
75
- return rules
76
-
77
-
78
- def make_rule(
79
- operation: APIOperation,
80
- bundle: Bundle,
81
- connections: APIOperationConnections,
82
- ) -> Rule | None:
83
- """Create a rule for an API operation."""
84
-
85
- def _make_rule(previous: st.SearchStrategy) -> Rule:
86
- decorator = rule(target=bundle, previous=previous, case=operation.as_strategy()) # type: ignore
87
- return decorator(APIStateMachine._step)
88
-
89
- incoming = connections.get(operation.verbose_name)
90
- if incoming is not None:
91
- incoming_connections = cast(List[Connection], incoming)
92
- strategies = [connection.strategy for connection in incoming_connections]
93
- _rule = _make_rule(combine_strategies(strategies))
94
-
95
- def has_source_response(self: OpenAPIStateMachine) -> bool:
96
- # To trigger this transition, there should be matching responses from the source operations
97
- return any(connection.source in self.bundles for connection in incoming_connections)
98
-
99
- return precondition(has_source_response)(_rule)
100
- # No incoming transitions - make rules only for operations that have at least one outgoing transition
101
- if any(
102
- connection.source == operation.verbose_name
103
- for operation_connections in connections.values()
104
- for connection in operation_connections
105
- ):
106
- return _make_rule(st.none())
107
- return None
189
+ expanded_status_codes = {
190
+ status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
191
+ }
192
+
193
+ def match_default_response(result: StepResult) -> bool:
194
+ return result.response.status_code not in expanded_status_codes
195
+
196
+ return match_default_response