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.
- schemathesis/__init__.py +3 -3
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +1 -3
- schemathesis/_hypothesis.py +6 -0
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +1 -0
- schemathesis/_rate_limiter.py +2 -1
- schemathesis/_xml.py +1 -0
- schemathesis/auths.py +4 -2
- schemathesis/checks.py +8 -5
- schemathesis/cli/__init__.py +28 -1
- schemathesis/cli/callbacks.py +3 -4
- schemathesis/cli/cassettes.py +6 -4
- schemathesis/cli/constants.py +2 -0
- schemathesis/cli/context.py +5 -0
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +1 -1
- schemathesis/cli/junitxml.py +5 -4
- schemathesis/cli/options.py +1 -0
- schemathesis/cli/output/default.py +56 -24
- schemathesis/cli/output/short.py +21 -10
- schemathesis/cli/sanitization.py +1 -0
- schemathesis/code_samples.py +1 -0
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +2 -0
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +2 -1
- schemathesis/exceptions.py +42 -61
- schemathesis/experimental/__init__.py +14 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +13 -24
- schemathesis/failures.py +42 -8
- schemathesis/filters.py +2 -1
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +2 -1
- schemathesis/hooks.py +3 -1
- schemathesis/internal/copy.py +19 -3
- schemathesis/internal/deprecation.py +1 -1
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/output.py +68 -0
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +1 -0
- schemathesis/lazy.py +11 -2
- schemathesis/loaders.py +4 -2
- schemathesis/models.py +22 -7
- schemathesis/parameters.py +1 -0
- schemathesis/runner/__init__.py +1 -1
- schemathesis/runner/events.py +22 -4
- schemathesis/runner/impl/core.py +69 -33
- schemathesis/runner/impl/solo.py +2 -1
- schemathesis/runner/impl/threadpool.py +4 -0
- schemathesis/runner/probes.py +1 -1
- schemathesis/runner/serialization.py +1 -1
- schemathesis/sanitization.py +2 -0
- schemathesis/schemas.py +7 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +7 -7
- schemathesis/service/events.py +2 -1
- schemathesis/service/extensions.py +5 -5
- schemathesis/service/hosts.py +1 -0
- schemathesis/service/metadata.py +2 -1
- schemathesis/service/models.py +2 -1
- schemathesis/service/report.py +3 -3
- schemathesis/service/serialization.py +62 -23
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +1 -1
- schemathesis/specs/graphql/loaders.py +17 -1
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +7 -7
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +17 -11
- schemathesis/specs/openapi/checks.py +102 -9
- schemathesis/specs/openapi/converter.py +2 -1
- schemathesis/specs/openapi/definitions.py +2 -1
- schemathesis/specs/openapi/examples.py +7 -9
- schemathesis/specs/openapi/expressions/__init__.py +29 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +19 -18
- schemathesis/specs/openapi/expressions/nodes.py +24 -4
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/filters.py +1 -0
- schemathesis/specs/openapi/links.py +35 -7
- schemathesis/specs/openapi/loaders.py +31 -11
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +1 -0
- schemathesis/specs/openapi/parameters.py +1 -0
- schemathesis/specs/openapi/schemas.py +28 -39
- schemathesis/specs/openapi/security.py +1 -0
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +159 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +13 -0
- schemathesis/specs/openapi/utils.py +1 -0
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +4 -2
- schemathesis/stateful/config.py +66 -0
- schemathesis/stateful/context.py +103 -0
- schemathesis/stateful/events.py +215 -0
- schemathesis/stateful/runner.py +238 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +39 -22
- schemathesis/stateful/statistic.py +20 -0
- schemathesis/stateful/validation.py +66 -0
- schemathesis/targets.py +1 -0
- schemathesis/throttling.py +23 -3
- schemathesis/transports/__init__.py +28 -10
- schemathesis/transports/auth.py +1 -0
- schemathesis/transports/content_types.py +1 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +6 -4
- schemathesis/types.py +1 -0
- schemathesis/utils.py +1 -0
- {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/METADATA +3 -3
- schemathesis-3.30.1.dist-info/RECORD +151 -0
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.29.2.dist-info/RECORD +0 -141
- {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.29.2.dist-info → schemathesis-3.30.1.dist-info}/entry_points.txt +0 -0
- {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
|
|
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 ...
|
|
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
|
|
@@ -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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,28 +1,46 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from collections import defaultdict
|
|
3
|
-
from
|
|
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,
|
|
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 ....
|
|
13
|
+
from ....types import NotSet
|
|
12
14
|
from .. import expressions
|
|
13
|
-
from
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
50
|
-
"""Create
|
|
183
|
+
def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
|
|
184
|
+
"""Create a filter that matches all "default" responses.
|
|
51
185
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|