schemathesis 3.29.2__py3-none-any.whl → 3.30.1__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 (125) 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 +28 -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 +5 -0
  16. schemathesis/cli/debug.py +2 -1
  17. schemathesis/cli/handlers.py +1 -1
  18. schemathesis/cli/junitxml.py +5 -4
  19. schemathesis/cli/options.py +1 -0
  20. schemathesis/cli/output/default.py +56 -24
  21. schemathesis/cli/output/short.py +21 -10
  22. schemathesis/cli/sanitization.py +1 -0
  23. schemathesis/code_samples.py +1 -0
  24. schemathesis/constants.py +1 -0
  25. schemathesis/contrib/openapi/__init__.py +1 -1
  26. schemathesis/contrib/openapi/fill_missing_examples.py +2 -0
  27. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  28. schemathesis/contrib/unique_data.py +2 -1
  29. schemathesis/exceptions.py +42 -61
  30. schemathesis/experimental/__init__.py +14 -0
  31. schemathesis/extra/_aiohttp.py +1 -0
  32. schemathesis/extra/_server.py +1 -0
  33. schemathesis/extra/pytest_plugin.py +13 -24
  34. schemathesis/failures.py +42 -8
  35. schemathesis/filters.py +2 -1
  36. schemathesis/fixups/__init__.py +1 -0
  37. schemathesis/fixups/fast_api.py +2 -2
  38. schemathesis/fixups/utf8_bom.py +1 -2
  39. schemathesis/generation/__init__.py +2 -1
  40. schemathesis/hooks.py +3 -1
  41. schemathesis/internal/copy.py +19 -3
  42. schemathesis/internal/deprecation.py +1 -1
  43. schemathesis/internal/jsonschema.py +2 -1
  44. schemathesis/internal/output.py +68 -0
  45. schemathesis/internal/result.py +1 -1
  46. schemathesis/internal/transformation.py +1 -0
  47. schemathesis/lazy.py +11 -2
  48. schemathesis/loaders.py +4 -2
  49. schemathesis/models.py +22 -7
  50. schemathesis/parameters.py +1 -0
  51. schemathesis/runner/__init__.py +1 -1
  52. schemathesis/runner/events.py +22 -4
  53. schemathesis/runner/impl/core.py +69 -33
  54. schemathesis/runner/impl/solo.py +2 -1
  55. schemathesis/runner/impl/threadpool.py +4 -0
  56. schemathesis/runner/probes.py +1 -1
  57. schemathesis/runner/serialization.py +1 -1
  58. schemathesis/sanitization.py +2 -0
  59. schemathesis/schemas.py +7 -4
  60. schemathesis/service/ci.py +1 -0
  61. schemathesis/service/client.py +7 -7
  62. schemathesis/service/events.py +2 -1
  63. schemathesis/service/extensions.py +5 -5
  64. schemathesis/service/hosts.py +1 -0
  65. schemathesis/service/metadata.py +2 -1
  66. schemathesis/service/models.py +2 -1
  67. schemathesis/service/report.py +3 -3
  68. schemathesis/service/serialization.py +62 -23
  69. schemathesis/service/usage.py +1 -0
  70. schemathesis/specs/graphql/_cache.py +1 -1
  71. schemathesis/specs/graphql/loaders.py +17 -1
  72. schemathesis/specs/graphql/nodes.py +1 -0
  73. schemathesis/specs/graphql/scalars.py +2 -2
  74. schemathesis/specs/graphql/schemas.py +7 -7
  75. schemathesis/specs/graphql/validation.py +1 -2
  76. schemathesis/specs/openapi/_hypothesis.py +17 -11
  77. schemathesis/specs/openapi/checks.py +102 -9
  78. schemathesis/specs/openapi/converter.py +2 -1
  79. schemathesis/specs/openapi/definitions.py +2 -1
  80. schemathesis/specs/openapi/examples.py +7 -9
  81. schemathesis/specs/openapi/expressions/__init__.py +29 -2
  82. schemathesis/specs/openapi/expressions/context.py +1 -1
  83. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  84. schemathesis/specs/openapi/expressions/lexer.py +19 -18
  85. schemathesis/specs/openapi/expressions/nodes.py +24 -4
  86. schemathesis/specs/openapi/expressions/parser.py +26 -5
  87. schemathesis/specs/openapi/filters.py +1 -0
  88. schemathesis/specs/openapi/links.py +35 -7
  89. schemathesis/specs/openapi/loaders.py +31 -11
  90. schemathesis/specs/openapi/negative/__init__.py +2 -1
  91. schemathesis/specs/openapi/negative/mutations.py +1 -0
  92. schemathesis/specs/openapi/parameters.py +1 -0
  93. schemathesis/specs/openapi/schemas.py +28 -39
  94. schemathesis/specs/openapi/security.py +1 -0
  95. schemathesis/specs/openapi/serialization.py +1 -0
  96. schemathesis/specs/openapi/stateful/__init__.py +159 -70
  97. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  98. schemathesis/specs/openapi/stateful/types.py +13 -0
  99. schemathesis/specs/openapi/utils.py +1 -0
  100. schemathesis/specs/openapi/validation.py +1 -0
  101. schemathesis/stateful/__init__.py +4 -2
  102. schemathesis/stateful/config.py +66 -0
  103. schemathesis/stateful/context.py +103 -0
  104. schemathesis/stateful/events.py +215 -0
  105. schemathesis/stateful/runner.py +238 -0
  106. schemathesis/stateful/sink.py +68 -0
  107. schemathesis/stateful/state_machine.py +39 -22
  108. schemathesis/stateful/statistic.py +20 -0
  109. schemathesis/stateful/validation.py +66 -0
  110. schemathesis/targets.py +1 -0
  111. schemathesis/throttling.py +23 -3
  112. schemathesis/transports/__init__.py +28 -10
  113. schemathesis/transports/auth.py +1 -0
  114. schemathesis/transports/content_types.py +1 -1
  115. schemathesis/transports/headers.py +2 -1
  116. schemathesis/transports/responses.py +6 -4
  117. schemathesis/types.py +1 -0
  118. schemathesis/utils.py +1 -0
  119. {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/METADATA +3 -3
  120. schemathesis-3.30.1.dist-info/RECORD +151 -0
  121. schemathesis/specs/openapi/stateful/links.py +0 -92
  122. schemathesis-3.29.2.dist-info/RECORD +0 -141
  123. {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/WHEEL +0 -0
  124. {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/entry_points.txt +0 -0
  125. {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,37 +1,39 @@
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.output import OutputConfig
22
+ from ...internal.validation import require_relative_url
20
23
  from ...loaders import load_schema_from_url, load_yaml
21
24
  from ...throttling import build_limiter
22
- from ...types import Filter, NotSet, PathLike
23
25
  from ...transports.content_types import is_json_media_type, is_yaml_media_type
24
26
  from ...transports.headers import setup_default_headers
25
- from ...internal.validation import require_relative_url
26
- from ...constants import NOT_SET
27
+ from ...types import Filter, NotSet, PathLike
27
28
  from . import definitions, validation
28
29
 
29
30
  if TYPE_CHECKING:
30
- from .schemas import BaseOpenAPISchema
31
- from ...transports.responses import GenericResponse
32
31
  import jsonschema
33
32
  from pyrate_limiter import Limiter
33
+
34
34
  from ...lazy import LazySchema
35
+ from ...transports.responses import GenericResponse
36
+ from .schemas import BaseOpenAPISchema
35
37
 
36
38
 
37
39
  def _is_json_response(response: GenericResponse) -> bool:
@@ -78,6 +80,7 @@ def from_path(
78
80
  force_schema_version: str | None = None,
79
81
  data_generation_methods: DataGenerationMethodInput = DEFAULT_DATA_GENERATION_METHODS,
80
82
  generation_config: GenerationConfig | None = None,
83
+ output_config: OutputConfig | None = None,
81
84
  code_sample_style: str = CodeSampleStyle.default().name,
82
85
  rate_limit: str | None = None,
83
86
  encoding: str = "utf8",
@@ -102,6 +105,7 @@ def from_path(
102
105
  force_schema_version=force_schema_version,
103
106
  data_generation_methods=data_generation_methods,
104
107
  generation_config=generation_config,
108
+ output_config=output_config,
105
109
  code_sample_style=code_sample_style,
106
110
  location=pathlib.Path(path).absolute().as_uri(),
107
111
  rate_limit=rate_limit,
@@ -126,6 +130,7 @@ def from_uri(
126
130
  force_schema_version: str | None = None,
127
131
  data_generation_methods: DataGenerationMethodInput = DEFAULT_DATA_GENERATION_METHODS,
128
132
  generation_config: GenerationConfig | None = None,
133
+ output_config: OutputConfig | None = None,
129
134
  code_sample_style: str = CodeSampleStyle.default().name,
130
135
  wait_for_schema: float | None = None,
131
136
  rate_limit: str | None = None,
@@ -175,6 +180,7 @@ def from_uri(
175
180
  force_schema_version=force_schema_version,
176
181
  data_generation_methods=data_generation_methods,
177
182
  generation_config=generation_config,
183
+ output_config=output_config,
178
184
  code_sample_style=code_sample_style,
179
185
  location=uri,
180
186
  rate_limit=rate_limit,
@@ -220,6 +226,7 @@ def from_file(
220
226
  force_schema_version: str | None = None,
221
227
  data_generation_methods: DataGenerationMethodInput = DEFAULT_DATA_GENERATION_METHODS,
222
228
  generation_config: GenerationConfig | None = None,
229
+ output_config: OutputConfig | None = None,
223
230
  code_sample_style: str = CodeSampleStyle.default().name,
224
231
  location: str | None = None,
225
232
  rate_limit: str | None = None,
@@ -266,6 +273,7 @@ def from_file(
266
273
  force_schema_version=force_schema_version,
267
274
  data_generation_methods=data_generation_methods,
268
275
  generation_config=generation_config,
276
+ output_config=output_config,
269
277
  code_sample_style=code_sample_style,
270
278
  location=location,
271
279
  rate_limit=rate_limit,
@@ -294,6 +302,7 @@ def from_dict(
294
302
  force_schema_version: str | None = None,
295
303
  data_generation_methods: DataGenerationMethodInput = DEFAULT_DATA_GENERATION_METHODS,
296
304
  generation_config: GenerationConfig | None = None,
305
+ output_config: OutputConfig | None = None,
297
306
  code_sample_style: str = CodeSampleStyle.default().name,
298
307
  location: str | None = None,
299
308
  rate_limit: str | None = None,
@@ -303,8 +312,8 @@ def from_dict(
303
312
 
304
313
  :param dict raw_schema: A schema to load.
305
314
  """
306
- from .schemas import OpenApi30, SwaggerV20
307
315
  from ... import transports
316
+ from .schemas import OpenApi30, SwaggerV20
308
317
 
309
318
  if not isinstance(raw_schema, dict):
310
319
  raise SchemaError(SchemaErrorType.OPEN_API_INVALID_SCHEMA, SCHEMA_INVALID_ERROR)
@@ -335,6 +344,7 @@ def from_dict(
335
344
  validate_schema=validate_schema,
336
345
  data_generation_methods=DataGenerationMethod.ensure_list(data_generation_methods),
337
346
  generation_config=generation_config or GenerationConfig(),
347
+ output_config=output_config or OutputConfig(),
338
348
  code_sample_style=_code_sample_style,
339
349
  location=location,
340
350
  rate_limiter=rate_limiter,
@@ -377,6 +387,7 @@ def from_dict(
377
387
  validate_schema=validate_schema,
378
388
  data_generation_methods=DataGenerationMethod.ensure_list(data_generation_methods),
379
389
  generation_config=generation_config or GenerationConfig(),
390
+ output_config=output_config or OutputConfig(),
380
391
  code_sample_style=_code_sample_style,
381
392
  location=location,
382
393
  rate_limiter=rate_limiter,
@@ -466,6 +477,7 @@ def from_pytest_fixture(
466
477
  validate_schema: bool = False,
467
478
  data_generation_methods: DataGenerationMethodInput | NotSet = NOT_SET,
468
479
  generation_config: GenerationConfig | NotSet = NOT_SET,
480
+ output_config: OutputConfig | NotSet = NOT_SET,
469
481
  code_sample_style: str = CodeSampleStyle.default().name,
470
482
  rate_limit: str | None = None,
471
483
  sanitize_output: bool = True,
@@ -503,6 +515,7 @@ def from_pytest_fixture(
503
515
  validate_schema=validate_schema,
504
516
  data_generation_methods=_data_generation_methods,
505
517
  generation_config=generation_config,
518
+ output_config=output_config,
506
519
  code_sample_style=_code_sample_style,
507
520
  rate_limiter=rate_limiter,
508
521
  sanitize_output=sanitize_output,
@@ -523,6 +536,7 @@ def from_wsgi(
523
536
  force_schema_version: str | None = None,
524
537
  data_generation_methods: DataGenerationMethodInput = DEFAULT_DATA_GENERATION_METHODS,
525
538
  generation_config: GenerationConfig | None = None,
539
+ output_config: OutputConfig | None = None,
526
540
  code_sample_style: str = CodeSampleStyle.default().name,
527
541
  rate_limit: str | None = None,
528
542
  sanitize_output: bool = True,
@@ -533,9 +547,10 @@ def from_wsgi(
533
547
  :param str schema_path: An in-app relative URL to the schema.
534
548
  :param app: A WSGI app instance.
535
549
  """
536
- from ...transports.responses import WSGIResponse
537
550
  from werkzeug.test import Client
538
551
 
552
+ from ...transports.responses import WSGIResponse
553
+
539
554
  require_relative_url(schema_path)
540
555
  setup_default_headers(kwargs)
541
556
  client = Client(app, WSGIResponse)
@@ -553,6 +568,7 @@ def from_wsgi(
553
568
  force_schema_version=force_schema_version,
554
569
  data_generation_methods=data_generation_methods,
555
570
  generation_config=generation_config,
571
+ output_config=output_config,
556
572
  code_sample_style=code_sample_style,
557
573
  location=schema_path,
558
574
  rate_limit=rate_limit,
@@ -585,6 +601,7 @@ def from_aiohttp(
585
601
  force_schema_version: str | None = None,
586
602
  data_generation_methods: DataGenerationMethodInput = DEFAULT_DATA_GENERATION_METHODS,
587
603
  generation_config: GenerationConfig | None = None,
604
+ output_config: OutputConfig | None = None,
588
605
  code_sample_style: str = CodeSampleStyle.default().name,
589
606
  rate_limit: str | None = None,
590
607
  sanitize_output: bool = True,
@@ -612,6 +629,7 @@ def from_aiohttp(
612
629
  force_schema_version=force_schema_version,
613
630
  data_generation_methods=data_generation_methods,
614
631
  generation_config=generation_config,
632
+ output_config=output_config,
615
633
  code_sample_style=code_sample_style,
616
634
  rate_limit=rate_limit,
617
635
  sanitize_output=sanitize_output,
@@ -633,6 +651,7 @@ def from_asgi(
633
651
  force_schema_version: str | None = None,
634
652
  data_generation_methods: DataGenerationMethodInput = DEFAULT_DATA_GENERATION_METHODS,
635
653
  generation_config: GenerationConfig | None = None,
654
+ output_config: OutputConfig | None = None,
636
655
  code_sample_style: str = CodeSampleStyle.default().name,
637
656
  rate_limit: str | None = None,
638
657
  sanitize_output: bool = True,
@@ -662,6 +681,7 @@ def from_asgi(
662
681
  force_schema_version=force_schema_version,
663
682
  data_generation_methods=data_generation_methods,
664
683
  generation_config=generation_config,
684
+ output_config=output_config,
665
685
  code_sample_style=code_sample_style,
666
686
  location=schema_path,
667
687
  rate_limit=rate_limit,
@@ -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,8 +656,8 @@ 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)
660
- ctx = failures.ValidationErrorContext.from_exception(exc)
659
+ exc_class = get_schema_validation_error(operation.verbose_name, exc)
660
+ ctx = failures.ValidationErrorContext.from_exception(exc, output_config=operation.schema.output_config)
661
661
  try:
662
662
  raise exc_class(ctx.title, context=ctx) from exc
663
663
  except Exception as 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