schemathesis 3.13.0__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1016
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +683 -247
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +27 -0
- schemathesis/specs/graphql/scalars.py +86 -0
- schemathesis/specs/graphql/schemas.py +395 -123
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +578 -317
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +753 -74
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +117 -68
- schemathesis/specs/openapi/negative/mutations.py +294 -104
- schemathesis/specs/openapi/negative/utils.py +3 -6
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +648 -650
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +404 -69
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -41
- schemathesis/_hypothesis.py +0 -115
- schemathesis/cli/callbacks.py +0 -188
- schemathesis/cli/cassettes.py +0 -253
- schemathesis/cli/context.py +0 -36
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -51
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -508
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -79
- schemathesis/exceptions.py +0 -207
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -216
- schemathesis/failures.py +0 -131
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/lazy.py +0 -227
- schemathesis/models.py +0 -1041
- schemathesis/parameters.py +0 -88
- schemathesis/runner/__init__.py +0 -460
- schemathesis/runner/events.py +0 -240
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -755
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -189
- schemathesis/serializers.py +0 -233
- schemathesis/service/__init__.py +0 -3
- schemathesis/service/client.py +0 -46
- schemathesis/service/constants.py +0 -12
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -39
- schemathesis/service/models.py +0 -7
- schemathesis/service/serialization.py +0 -153
- schemathesis/service/worker.py +0 -40
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -302
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -413
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -349
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -436
- schemathesis-3.13.0.dist-info/METADATA +0 -202
- schemathesis-3.13.0.dist-info/RECORD +0 -91
- schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
schemathesis/schemas.py
CHANGED
|
@@ -1,122 +1,262 @@
|
|
|
1
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
Their responsibilities:
|
|
4
|
-
- Provide a unified way to work with different types of schemas
|
|
5
|
-
- Give all paths / methods combinations that are available directly from the schema;
|
|
6
|
-
|
|
7
|
-
They give only static definitions of paths.
|
|
8
|
-
"""
|
|
9
3
|
from collections.abc import Mapping
|
|
10
|
-
from
|
|
11
|
-
from functools import lru_cache
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from functools import cached_property, lru_cache, partial
|
|
6
|
+
from itertools import chain
|
|
12
7
|
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
13
9
|
Any,
|
|
14
10
|
Callable,
|
|
15
|
-
Dict,
|
|
16
11
|
Generator,
|
|
17
|
-
|
|
12
|
+
Generic,
|
|
18
13
|
Iterator,
|
|
19
|
-
List,
|
|
20
14
|
NoReturn,
|
|
21
|
-
Optional,
|
|
22
|
-
Sequence,
|
|
23
|
-
Tuple,
|
|
24
|
-
Type,
|
|
25
15
|
TypeVar,
|
|
26
|
-
Union,
|
|
27
16
|
)
|
|
28
17
|
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
|
29
18
|
|
|
30
|
-
import
|
|
31
|
-
import
|
|
32
|
-
from
|
|
33
|
-
from
|
|
34
|
-
|
|
35
|
-
from .
|
|
36
|
-
from .
|
|
37
|
-
from .
|
|
38
|
-
from .
|
|
39
|
-
from .
|
|
40
|
-
from .
|
|
41
|
-
from .
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
PathParameters,
|
|
51
|
-
Query,
|
|
19
|
+
from schemathesis import transport
|
|
20
|
+
from schemathesis.config import ProjectConfig
|
|
21
|
+
from schemathesis.core import NOT_SET, NotSet, media_types
|
|
22
|
+
from schemathesis.core.adapter import OperationParameter, ResponsesContainer
|
|
23
|
+
from schemathesis.core.errors import IncorrectUsage, InvalidSchema
|
|
24
|
+
from schemathesis.core.result import Ok, Result
|
|
25
|
+
from schemathesis.core.transport import Response
|
|
26
|
+
from schemathesis.generation import GenerationMode
|
|
27
|
+
from schemathesis.generation.case import Case
|
|
28
|
+
from schemathesis.generation.hypothesis.given import GivenInput, given_proxy
|
|
29
|
+
from schemathesis.generation.meta import CaseMetadata
|
|
30
|
+
from schemathesis.hooks import HookDispatcherMark, _should_skip_hook
|
|
31
|
+
|
|
32
|
+
from .auths import AuthStorage
|
|
33
|
+
from .filters import (
|
|
34
|
+
FilterSet,
|
|
35
|
+
FilterValue,
|
|
36
|
+
MatcherFunc,
|
|
37
|
+
RegexValue,
|
|
38
|
+
is_deprecated,
|
|
52
39
|
)
|
|
53
|
-
from .
|
|
40
|
+
from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, HookScope, dispatch, to_filterable_hook
|
|
54
41
|
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
import httpx
|
|
44
|
+
import requests
|
|
45
|
+
from hypothesis.strategies import SearchStrategy
|
|
46
|
+
from requests.structures import CaseInsensitiveDict
|
|
47
|
+
from werkzeug.test import TestResponse
|
|
55
48
|
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
from schemathesis.core import Specification
|
|
50
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
|
58
51
|
|
|
59
|
-
Provides a more specific error message if API operation is not found.
|
|
60
|
-
"""
|
|
61
52
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
except KeyError as exc:
|
|
66
|
-
available_methods = ", ".join(map(str.upper, self))
|
|
67
|
-
message = f"Method `{item}` not found. Available methods: {available_methods}"
|
|
68
|
-
raise KeyError(message) from exc
|
|
53
|
+
@lru_cache
|
|
54
|
+
def get_full_path(base_path: str, path: str) -> str:
|
|
55
|
+
return unquote(urljoin(base_path, quote(path.lstrip("/"))))
|
|
69
56
|
|
|
70
57
|
|
|
71
|
-
|
|
58
|
+
@dataclass
|
|
59
|
+
class FilteredCount:
|
|
60
|
+
"""Count of total items and those passing filters."""
|
|
72
61
|
|
|
62
|
+
total: int
|
|
63
|
+
selected: int
|
|
73
64
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
__slots__ = ("total", "selected")
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self.total = 0
|
|
69
|
+
self.selected = 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ApiStatistic:
|
|
74
|
+
"""Statistics about API operations and links."""
|
|
75
|
+
|
|
76
|
+
operations: FilteredCount
|
|
77
|
+
links: FilteredCount
|
|
78
|
+
|
|
79
|
+
__slots__ = ("operations", "links")
|
|
77
80
|
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self.operations = FilteredCount()
|
|
83
|
+
self.links = FilteredCount()
|
|
78
84
|
|
|
79
|
-
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ApiOperationsCount:
|
|
88
|
+
"""Statistics about API operations."""
|
|
89
|
+
|
|
90
|
+
total: int
|
|
91
|
+
selected: int
|
|
92
|
+
|
|
93
|
+
__slots__ = ("total", "selected")
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
self.total = 0
|
|
97
|
+
self.selected = 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(eq=False)
|
|
80
101
|
class BaseSchema(Mapping):
|
|
81
|
-
raw_schema:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
raw_schema: dict[str, Any]
|
|
103
|
+
config: ProjectConfig
|
|
104
|
+
location: str | None = None
|
|
105
|
+
filter_set: FilterSet = field(default_factory=FilterSet)
|
|
106
|
+
app: Any = None
|
|
107
|
+
hooks: HookDispatcher = field(default_factory=lambda: HookDispatcher(scope=HookScope.SCHEMA))
|
|
108
|
+
auth: AuthStorage = field(default_factory=AuthStorage)
|
|
109
|
+
test_function: Callable | None = None
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
self.hook = to_filterable_hook(self.hooks) # type: ignore[method-assign]
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def specification(self) -> Specification:
|
|
116
|
+
raise NotImplementedError
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def transport(self) -> transport.BaseTransport:
|
|
120
|
+
return transport.get(self.app)
|
|
121
|
+
|
|
122
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
|
123
|
+
|
|
124
|
+
def include(
|
|
125
|
+
self,
|
|
126
|
+
func: MatcherFunc | None = None,
|
|
127
|
+
*,
|
|
128
|
+
name: FilterValue | None = None,
|
|
129
|
+
name_regex: str | None = None,
|
|
130
|
+
method: FilterValue | None = None,
|
|
131
|
+
method_regex: str | None = None,
|
|
132
|
+
path: FilterValue | None = None,
|
|
133
|
+
path_regex: str | None = None,
|
|
134
|
+
tag: FilterValue | None = None,
|
|
135
|
+
tag_regex: RegexValue | None = None,
|
|
136
|
+
operation_id: FilterValue | None = None,
|
|
137
|
+
operation_id_regex: RegexValue | None = None,
|
|
138
|
+
) -> BaseSchema:
|
|
139
|
+
"""Return a new schema containing only operations matching the specified criteria.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
func: Custom filter function that accepts operation context.
|
|
143
|
+
name: Operation name(s) to include.
|
|
144
|
+
name_regex: Regex pattern for operation names.
|
|
145
|
+
method: HTTP method(s) to include.
|
|
146
|
+
method_regex: Regex pattern for HTTP methods.
|
|
147
|
+
path: API path(s) to include.
|
|
148
|
+
path_regex: Regex pattern for API paths.
|
|
149
|
+
tag: OpenAPI tag(s) to include.
|
|
150
|
+
tag_regex: Regex pattern for OpenAPI tags.
|
|
151
|
+
operation_id: Operation ID(s) to include.
|
|
152
|
+
operation_id_regex: Regex pattern for operation IDs.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
New schema instance with applied include filters.
|
|
156
|
+
|
|
157
|
+
"""
|
|
158
|
+
filter_set = self.filter_set.clone()
|
|
159
|
+
filter_set.include(
|
|
160
|
+
func,
|
|
161
|
+
name=name,
|
|
162
|
+
name_regex=name_regex,
|
|
163
|
+
method=method,
|
|
164
|
+
method_regex=method_regex,
|
|
165
|
+
path=path,
|
|
166
|
+
path_regex=path_regex,
|
|
167
|
+
tag=tag,
|
|
168
|
+
tag_regex=tag_regex,
|
|
169
|
+
operation_id=operation_id,
|
|
170
|
+
operation_id_regex=operation_id_regex,
|
|
171
|
+
)
|
|
172
|
+
return self.clone(filter_set=filter_set)
|
|
173
|
+
|
|
174
|
+
def exclude(
|
|
175
|
+
self,
|
|
176
|
+
func: MatcherFunc | None = None,
|
|
177
|
+
*,
|
|
178
|
+
name: FilterValue | None = None,
|
|
179
|
+
name_regex: str | None = None,
|
|
180
|
+
method: FilterValue | None = None,
|
|
181
|
+
method_regex: str | None = None,
|
|
182
|
+
path: FilterValue | None = None,
|
|
183
|
+
path_regex: str | None = None,
|
|
184
|
+
tag: FilterValue | None = None,
|
|
185
|
+
tag_regex: RegexValue | None = None,
|
|
186
|
+
operation_id: FilterValue | None = None,
|
|
187
|
+
operation_id_regex: RegexValue | None = None,
|
|
188
|
+
deprecated: bool = False,
|
|
189
|
+
) -> BaseSchema:
|
|
190
|
+
"""Return a new schema excluding operations matching the specified criteria.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
func: Custom filter function that accepts operation context.
|
|
194
|
+
name: Operation name(s) to exclude.
|
|
195
|
+
name_regex: Regex pattern for operation names.
|
|
196
|
+
method: HTTP method(s) to exclude.
|
|
197
|
+
method_regex: Regex pattern for HTTP methods.
|
|
198
|
+
path: API path(s) to exclude.
|
|
199
|
+
path_regex: Regex pattern for API paths.
|
|
200
|
+
tag: OpenAPI tag(s) to exclude.
|
|
201
|
+
tag_regex: Regex pattern for OpenAPI tags.
|
|
202
|
+
operation_id: Operation ID(s) to exclude.
|
|
203
|
+
operation_id_regex: Regex pattern for operation IDs.
|
|
204
|
+
deprecated: Whether to exclude deprecated operations.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
New schema instance with applied exclude filters.
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
filter_set = self.filter_set.clone()
|
|
211
|
+
if deprecated:
|
|
212
|
+
if func is None:
|
|
213
|
+
func = is_deprecated
|
|
214
|
+
else:
|
|
215
|
+
filter_set.exclude(is_deprecated)
|
|
216
|
+
filter_set.exclude(
|
|
217
|
+
func,
|
|
218
|
+
name=name,
|
|
219
|
+
name_regex=name_regex,
|
|
220
|
+
method=method,
|
|
221
|
+
method_regex=method_regex,
|
|
222
|
+
path=path,
|
|
223
|
+
path_regex=path_regex,
|
|
224
|
+
tag=tag,
|
|
225
|
+
tag_regex=tag_regex,
|
|
226
|
+
operation_id=operation_id,
|
|
227
|
+
operation_id_regex=operation_id_regex,
|
|
228
|
+
)
|
|
229
|
+
return self.clone(filter_set=filter_set)
|
|
97
230
|
|
|
98
231
|
def __iter__(self) -> Iterator[str]:
|
|
99
|
-
|
|
232
|
+
raise NotImplementedError
|
|
100
233
|
|
|
101
|
-
def __getitem__(self, item: str) ->
|
|
234
|
+
def __getitem__(self, item: str) -> APIOperationMap:
|
|
235
|
+
__tracebackhide__ = True
|
|
102
236
|
try:
|
|
103
|
-
return self.
|
|
237
|
+
return self._get_operation_map(item)
|
|
104
238
|
except KeyError as exc:
|
|
105
|
-
|
|
106
|
-
message = f"`{item}` not found"
|
|
107
|
-
if matches:
|
|
108
|
-
message += f". Did you mean `{matches[0]}`?"
|
|
109
|
-
raise KeyError(message) from exc
|
|
239
|
+
self.on_missing_operation(item, exc)
|
|
110
240
|
|
|
111
|
-
def
|
|
112
|
-
|
|
241
|
+
def _get_operation_map(self, key: str) -> APIOperationMap:
|
|
242
|
+
raise NotImplementedError
|
|
113
243
|
|
|
114
|
-
|
|
115
|
-
def verbose_name(self) -> str:
|
|
244
|
+
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
|
116
245
|
raise NotImplementedError
|
|
117
246
|
|
|
247
|
+
def __len__(self) -> int:
|
|
248
|
+
return self.statistic.operations.total
|
|
249
|
+
|
|
250
|
+
def hook(self, hook: str | Callable) -> Callable:
|
|
251
|
+
"""Register a hook function for this schema only.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
hook: Hook name string or hook function to register.
|
|
255
|
+
|
|
256
|
+
"""
|
|
257
|
+
return self.hooks.hook(hook)
|
|
258
|
+
|
|
118
259
|
def get_full_path(self, path: str) -> str:
|
|
119
|
-
"""Compute full path for the given path."""
|
|
120
260
|
return get_full_path(self.base_path, path)
|
|
121
261
|
|
|
122
262
|
@property
|
|
@@ -124,8 +264,8 @@ class BaseSchema(Mapping):
|
|
|
124
264
|
"""Base path for the schema."""
|
|
125
265
|
# if `base_url` is specified, then it should include base path
|
|
126
266
|
# Example: http://127.0.0.1:8080/api
|
|
127
|
-
if self.base_url:
|
|
128
|
-
path = urlsplit(self.base_url).path
|
|
267
|
+
if self.config.base_url:
|
|
268
|
+
path = urlsplit(self.config.base_url).path
|
|
129
269
|
else:
|
|
130
270
|
path = self._get_base_path()
|
|
131
271
|
if not path.endswith("/"):
|
|
@@ -140,85 +280,57 @@ class BaseSchema(Mapping):
|
|
|
140
280
|
parts = urlsplit(self.location or "")[:2] + (path, "", "")
|
|
141
281
|
return urlunsplit(parts)
|
|
142
282
|
|
|
283
|
+
@cached_property
|
|
284
|
+
def _cached_base_url(self) -> str:
|
|
285
|
+
"""Cached base URL computation since schema doesn't change."""
|
|
286
|
+
return self._build_base_url()
|
|
287
|
+
|
|
143
288
|
def get_base_url(self) -> str:
|
|
144
|
-
base_url = self.base_url
|
|
289
|
+
base_url = self.config.base_url
|
|
145
290
|
if base_url is not None:
|
|
146
|
-
return base_url.rstrip("/")
|
|
147
|
-
return self.
|
|
291
|
+
return base_url.rstrip("/")
|
|
292
|
+
return self._cached_base_url
|
|
148
293
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if not hasattr(self, "_operations"):
|
|
152
|
-
# pylint: disable=attribute-defined-outside-init
|
|
153
|
-
operations = self.get_all_operations()
|
|
154
|
-
self._operations = operations_to_dict(operations)
|
|
155
|
-
return self._operations
|
|
294
|
+
def validate(self) -> None:
|
|
295
|
+
raise NotImplementedError
|
|
156
296
|
|
|
157
|
-
@
|
|
158
|
-
def
|
|
297
|
+
@cached_property
|
|
298
|
+
def statistic(self) -> ApiStatistic:
|
|
299
|
+
return self._measure_statistic()
|
|
300
|
+
|
|
301
|
+
def _measure_statistic(self) -> ApiStatistic:
|
|
159
302
|
raise NotImplementedError
|
|
160
303
|
|
|
161
304
|
def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
|
162
305
|
raise NotImplementedError
|
|
163
306
|
|
|
164
|
-
def get_strategies_from_examples(self, operation: APIOperation) ->
|
|
165
|
-
"""Get examples from the API operation."""
|
|
307
|
+
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
|
166
308
|
raise NotImplementedError
|
|
167
309
|
|
|
168
|
-
def
|
|
169
|
-
self, response: GenericResponse, operation: APIOperation, stateful: Optional[Stateful]
|
|
170
|
-
) -> Sequence[StatefulTest]:
|
|
171
|
-
"""Get a list of additional tests, that should be executed after this response from the API operation."""
|
|
310
|
+
def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
|
|
172
311
|
raise NotImplementedError
|
|
173
312
|
|
|
174
|
-
def
|
|
175
|
-
"""
|
|
176
|
-
raise NotImplementedError
|
|
313
|
+
def parametrize(self) -> Callable:
|
|
314
|
+
"""Return a decorator that marks a test function for `pytest` parametrization.
|
|
177
315
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
func: Callable,
|
|
181
|
-
settings: Optional[hypothesis.settings] = None,
|
|
182
|
-
seed: Optional[int] = None,
|
|
183
|
-
_given_kwargs: Optional[Dict[str, GivenInput]] = None,
|
|
184
|
-
) -> Generator[Tuple[Result[Tuple[APIOperation, Callable], InvalidSchema], DataGenerationMethod], None, None]:
|
|
185
|
-
"""Generate all operations and Hypothesis tests for them."""
|
|
186
|
-
for result in self.get_all_operations():
|
|
187
|
-
for data_generation_method in self.data_generation_methods:
|
|
188
|
-
if isinstance(result, Ok):
|
|
189
|
-
test = create_test(
|
|
190
|
-
operation=result.ok(),
|
|
191
|
-
test=func,
|
|
192
|
-
settings=settings,
|
|
193
|
-
seed=seed,
|
|
194
|
-
data_generation_method=data_generation_method,
|
|
195
|
-
_given_kwargs=_given_kwargs,
|
|
196
|
-
)
|
|
197
|
-
yield Ok((result.ok(), test)), data_generation_method
|
|
198
|
-
else:
|
|
199
|
-
yield result, data_generation_method
|
|
316
|
+
The decorated test function will be parametrized with test cases generated
|
|
317
|
+
from the schema's API operations.
|
|
200
318
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
) -> Callable:
|
|
212
|
-
"""Mark a test function as a parametrized one."""
|
|
213
|
-
_code_sample_style = (
|
|
214
|
-
CodeSampleStyle.from_str(code_sample_style) if isinstance(code_sample_style, str) else code_sample_style
|
|
215
|
-
)
|
|
319
|
+
Returns:
|
|
320
|
+
Decorator function for test parametrization.
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
IncorrectUsage: If applied to the same function multiple times.
|
|
324
|
+
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def wrapper(func: Callable) -> Callable:
|
|
328
|
+
from schemathesis.pytest.plugin import SchemaHandleMark
|
|
216
329
|
|
|
217
|
-
|
|
218
|
-
if hasattr(func, PARAMETRIZE_MARKER):
|
|
330
|
+
if SchemaHandleMark.is_set(func):
|
|
219
331
|
|
|
220
332
|
def wrapped_test(*_: Any, **__: Any) -> NoReturn:
|
|
221
|
-
raise
|
|
333
|
+
raise IncorrectUsage(
|
|
222
334
|
f"You have applied `parametrize` to the `{func.__name__}` test more than once, which "
|
|
223
335
|
"overrides the previous decorator. "
|
|
224
336
|
"The `parametrize` decorator could be applied to the same function at most once."
|
|
@@ -226,92 +338,53 @@ class BaseSchema(Mapping):
|
|
|
226
338
|
|
|
227
339
|
return wrapped_test
|
|
228
340
|
HookDispatcher.add_dispatcher(func)
|
|
229
|
-
cloned = self.clone(
|
|
230
|
-
|
|
231
|
-
method=method,
|
|
232
|
-
endpoint=endpoint,
|
|
233
|
-
tag=tag,
|
|
234
|
-
operation_id=operation_id,
|
|
235
|
-
validate_schema=validate_schema,
|
|
236
|
-
skip_deprecated_operations=skip_deprecated_operations,
|
|
237
|
-
data_generation_methods=data_generation_methods,
|
|
238
|
-
code_sample_style=_code_sample_style, # type: ignore
|
|
239
|
-
)
|
|
240
|
-
setattr(func, PARAMETRIZE_MARKER, cloned)
|
|
341
|
+
cloned = self.clone(test_function=func)
|
|
342
|
+
SchemaHandleMark.set(func, cloned)
|
|
241
343
|
return func
|
|
242
344
|
|
|
243
345
|
return wrapper
|
|
244
346
|
|
|
245
347
|
def given(self, *args: GivenInput, **kwargs: GivenInput) -> Callable:
|
|
246
|
-
"""Proxy Hypothesis
|
|
348
|
+
"""Proxy to Hypothesis's `given` decorator for adding custom strategies.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
*args: Positional arguments passed to `hypothesis.given`.
|
|
352
|
+
**kwargs: Keyword arguments passed to `hypothesis.given`.
|
|
353
|
+
|
|
354
|
+
"""
|
|
247
355
|
return given_proxy(*args, **kwargs)
|
|
248
356
|
|
|
249
357
|
def clone(
|
|
250
|
-
self,
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
validate_schema: Union[bool, NotSet] = NOT_SET,
|
|
261
|
-
skip_deprecated_operations: Union[bool, NotSet] = NOT_SET,
|
|
262
|
-
data_generation_methods: Union[DataGenerationMethodInput, NotSet] = NOT_SET,
|
|
263
|
-
code_sample_style: Union[CodeSampleStyle, NotSet] = NOT_SET,
|
|
264
|
-
) -> "BaseSchema":
|
|
265
|
-
if base_url is NOT_SET:
|
|
266
|
-
base_url = self.base_url
|
|
267
|
-
if method is NOT_SET:
|
|
268
|
-
method = self.method
|
|
269
|
-
if endpoint is NOT_SET:
|
|
270
|
-
endpoint = self.endpoint
|
|
271
|
-
if tag is NOT_SET:
|
|
272
|
-
tag = self.tag
|
|
273
|
-
if operation_id is NOT_SET:
|
|
274
|
-
operation_id = self.operation_id
|
|
275
|
-
if app is NOT_SET:
|
|
276
|
-
app = self.app
|
|
277
|
-
if validate_schema is NOT_SET:
|
|
278
|
-
validate_schema = self.validate_schema
|
|
279
|
-
if skip_deprecated_operations is NOT_SET:
|
|
280
|
-
skip_deprecated_operations = self.skip_deprecated_operations
|
|
281
|
-
if hooks is NOT_SET:
|
|
282
|
-
hooks = self.hooks
|
|
283
|
-
if data_generation_methods is NOT_SET:
|
|
284
|
-
data_generation_methods = self.data_generation_methods
|
|
285
|
-
if code_sample_style is NOT_SET:
|
|
286
|
-
code_sample_style = self.code_sample_style
|
|
358
|
+
self, *, test_function: Callable | NotSet = NOT_SET, filter_set: FilterSet | NotSet = NOT_SET
|
|
359
|
+
) -> BaseSchema:
|
|
360
|
+
if isinstance(test_function, NotSet):
|
|
361
|
+
_test_function = self.test_function
|
|
362
|
+
else:
|
|
363
|
+
_test_function = test_function
|
|
364
|
+
if isinstance(filter_set, NotSet):
|
|
365
|
+
_filter_set = self.filter_set
|
|
366
|
+
else:
|
|
367
|
+
_filter_set = filter_set
|
|
287
368
|
|
|
288
369
|
return self.__class__(
|
|
289
370
|
self.raw_schema,
|
|
371
|
+
config=self.config,
|
|
290
372
|
location=self.location,
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
app=app,
|
|
297
|
-
hooks=hooks, # type: ignore
|
|
298
|
-
test_function=test_function,
|
|
299
|
-
validate_schema=validate_schema, # type: ignore
|
|
300
|
-
skip_deprecated_operations=skip_deprecated_operations, # type: ignore
|
|
301
|
-
data_generation_methods=data_generation_methods, # type: ignore
|
|
302
|
-
code_sample_style=code_sample_style, # type: ignore
|
|
373
|
+
app=self.app,
|
|
374
|
+
hooks=self.hooks,
|
|
375
|
+
auth=self.auth,
|
|
376
|
+
test_function=_test_function,
|
|
377
|
+
filter_set=_filter_set,
|
|
303
378
|
)
|
|
304
379
|
|
|
305
|
-
def get_local_hook_dispatcher(self) ->
|
|
306
|
-
"""Get a HookDispatcher instance bound to the test if present."""
|
|
380
|
+
def get_local_hook_dispatcher(self) -> HookDispatcher | None:
|
|
307
381
|
# It might be not present when it is used without pytest via `APIOperation.as_strategy()`
|
|
308
382
|
if self.test_function is not None:
|
|
309
383
|
# Might be missing it in case of `LazySchema` usage
|
|
310
|
-
return
|
|
384
|
+
return HookDispatcherMark.get(self.test_function)
|
|
311
385
|
return None
|
|
312
386
|
|
|
313
387
|
def dispatch_hook(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None:
|
|
314
|
-
"""Dispatch a hook via all available dispatchers."""
|
|
315
388
|
dispatch(name, context, *args, **kwargs)
|
|
316
389
|
self.hooks.dispatch(name, context, *args, **kwargs)
|
|
317
390
|
local_dispatcher = self.get_local_hook_dispatcher()
|
|
@@ -319,63 +392,426 @@ class BaseSchema(Mapping):
|
|
|
319
392
|
local_dispatcher.dispatch(name, context, *args, **kwargs)
|
|
320
393
|
|
|
321
394
|
def prepare_multipart(
|
|
322
|
-
self, form_data:
|
|
323
|
-
) ->
|
|
324
|
-
"""Split content of `form_data` into files & data.
|
|
325
|
-
|
|
326
|
-
Forms may contain file fields, that we should send via `files` argument in `requests`.
|
|
327
|
-
"""
|
|
395
|
+
self, form_data: dict[str, Any], operation: APIOperation
|
|
396
|
+
) -> tuple[list | None, dict[str, Any] | None]:
|
|
328
397
|
raise NotImplementedError
|
|
329
398
|
|
|
330
|
-
def get_request_payload_content_types(self, operation: APIOperation) ->
|
|
399
|
+
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
331
400
|
raise NotImplementedError
|
|
332
401
|
|
|
333
402
|
def make_case(
|
|
334
403
|
self,
|
|
335
404
|
*,
|
|
336
|
-
case_cls: Type[C],
|
|
337
405
|
operation: APIOperation,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
406
|
+
method: str | None = None,
|
|
407
|
+
path: str | None = None,
|
|
408
|
+
path_parameters: dict[str, Any] | None = None,
|
|
409
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
|
410
|
+
cookies: dict[str, Any] | None = None,
|
|
411
|
+
query: dict[str, Any] | None = None,
|
|
412
|
+
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
|
413
|
+
media_type: str | None = None,
|
|
414
|
+
meta: CaseMetadata | None = None,
|
|
415
|
+
) -> Case:
|
|
345
416
|
raise NotImplementedError
|
|
346
417
|
|
|
347
418
|
def get_case_strategy(
|
|
348
419
|
self,
|
|
349
420
|
operation: APIOperation,
|
|
350
|
-
hooks:
|
|
351
|
-
|
|
421
|
+
hooks: HookDispatcher | None = None,
|
|
422
|
+
auth_storage: AuthStorage | None = None,
|
|
423
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
|
424
|
+
**kwargs: Any,
|
|
352
425
|
) -> SearchStrategy:
|
|
353
426
|
raise NotImplementedError
|
|
354
427
|
|
|
355
|
-
def as_state_machine(self) ->
|
|
356
|
-
"""Create a state machine class.
|
|
428
|
+
def as_state_machine(self) -> type[APIStateMachine]:
|
|
429
|
+
"""Create a state machine class for stateful testing of linked API operations.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
APIStateMachine subclass configured for this schema.
|
|
357
433
|
|
|
358
|
-
Use it for stateful testing.
|
|
359
434
|
"""
|
|
360
435
|
raise NotImplementedError
|
|
361
436
|
|
|
362
|
-
def
|
|
437
|
+
def get_tags(self, operation: APIOperation) -> list[str] | None:
|
|
363
438
|
raise NotImplementedError
|
|
364
439
|
|
|
365
|
-
def validate_response(
|
|
440
|
+
def validate_response(
|
|
441
|
+
self,
|
|
442
|
+
operation: APIOperation,
|
|
443
|
+
response: Response,
|
|
444
|
+
*,
|
|
445
|
+
case: Case | None = None,
|
|
446
|
+
) -> bool | None:
|
|
366
447
|
raise NotImplementedError
|
|
367
448
|
|
|
368
|
-
def
|
|
449
|
+
def as_strategy(
|
|
450
|
+
self,
|
|
451
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
|
452
|
+
**kwargs: Any,
|
|
453
|
+
) -> SearchStrategy:
|
|
454
|
+
"""Create a Hypothesis strategy that generates test cases for all schema operations.
|
|
455
|
+
|
|
456
|
+
Use with `@given` in non-Schemathesis tests.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
generation_mode: Whether to generate positive or negative test data.
|
|
460
|
+
**kwargs: Additional keywords for each strategy.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Combined Hypothesis strategy for all valid operations in the schema.
|
|
464
|
+
|
|
465
|
+
"""
|
|
466
|
+
from hypothesis import strategies as st
|
|
467
|
+
|
|
468
|
+
_strategies = [
|
|
469
|
+
operation.ok().as_strategy(generation_mode=generation_mode, **kwargs)
|
|
470
|
+
for operation in self.get_all_operations()
|
|
471
|
+
if isinstance(operation, Ok)
|
|
472
|
+
]
|
|
473
|
+
return st.one_of(_strategies)
|
|
474
|
+
|
|
475
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
|
369
476
|
raise NotImplementedError
|
|
370
477
|
|
|
371
478
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
479
|
+
@dataclass
|
|
480
|
+
class APIOperationMap(Mapping):
|
|
481
|
+
_schema: BaseSchema
|
|
482
|
+
_data: Mapping
|
|
483
|
+
|
|
484
|
+
__slots__ = ("_schema", "_data")
|
|
485
|
+
|
|
486
|
+
def __getitem__(self, item: str) -> APIOperation:
|
|
487
|
+
return self._data[item]
|
|
488
|
+
|
|
489
|
+
def __len__(self) -> int:
|
|
490
|
+
return len(self._data)
|
|
491
|
+
|
|
492
|
+
def __iter__(self) -> Iterator[str]:
|
|
493
|
+
return iter(self._data)
|
|
494
|
+
|
|
495
|
+
def as_strategy(
|
|
496
|
+
self,
|
|
497
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
|
498
|
+
**kwargs: Any,
|
|
499
|
+
) -> SearchStrategy:
|
|
500
|
+
"""Create a Hypothesis strategy that generates test cases for all schema operations in this subset.
|
|
501
|
+
|
|
502
|
+
Use with `@given` in non-Schemathesis tests.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
generation_mode: Whether to generate positive or negative test data.
|
|
506
|
+
**kwargs: Additional keywords for each strategy.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Combined Hypothesis strategy for all valid operations in the schema.
|
|
510
|
+
|
|
511
|
+
"""
|
|
512
|
+
from hypothesis import strategies as st
|
|
513
|
+
|
|
514
|
+
_strategies = [
|
|
515
|
+
operation.as_strategy(generation_mode=generation_mode, **kwargs) for operation in self._data.values()
|
|
516
|
+
]
|
|
517
|
+
return st.one_of(_strategies)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
P = TypeVar("P", bound=OperationParameter)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@dataclass
|
|
524
|
+
class ParameterSet(Generic[P]):
|
|
525
|
+
"""A set of parameters for the same location."""
|
|
526
|
+
|
|
527
|
+
items: list[P]
|
|
528
|
+
|
|
529
|
+
__slots__ = ("items",)
|
|
530
|
+
|
|
531
|
+
def __init__(self, items: list[P] | None = None) -> None:
|
|
532
|
+
self.items = items or []
|
|
533
|
+
|
|
534
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
|
535
|
+
|
|
536
|
+
def add(self, parameter: P) -> None:
|
|
537
|
+
"""Add a new parameter."""
|
|
538
|
+
self.items.append(parameter)
|
|
539
|
+
|
|
540
|
+
def get(self, name: str) -> P | None:
|
|
541
|
+
for parameter in self:
|
|
542
|
+
if parameter.name == name:
|
|
543
|
+
return parameter
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
def __contains__(self, name: str) -> bool:
|
|
547
|
+
for parameter in self.items:
|
|
548
|
+
if parameter.name == name:
|
|
549
|
+
return True
|
|
550
|
+
return False
|
|
551
|
+
|
|
552
|
+
def __iter__(self) -> Generator[P, None, None]:
|
|
553
|
+
yield from iter(self.items)
|
|
554
|
+
|
|
555
|
+
def __len__(self) -> int:
|
|
556
|
+
return len(self.items)
|
|
557
|
+
|
|
558
|
+
def __getitem__(self, item: int) -> P:
|
|
559
|
+
return self.items[item]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class PayloadAlternatives(ParameterSet[P]):
|
|
563
|
+
"""A set of alternative payloads."""
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
R = TypeVar("R", bound=ResponsesContainer)
|
|
567
|
+
S = TypeVar("S")
|
|
568
|
+
D = TypeVar("D", bound=dict)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@dataclass(repr=False)
|
|
572
|
+
class OperationDefinition(Generic[D]):
|
|
573
|
+
"""A wrapper to store not resolved API operation definitions.
|
|
574
|
+
|
|
575
|
+
To prevent recursion errors we need to store definitions without resolving references. But operation definitions
|
|
576
|
+
itself can be behind a reference (when there is a ``$ref`` in ``paths`` values), therefore we need to store this
|
|
577
|
+
scope change to have a proper reference resolving later.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
raw: D
|
|
581
|
+
|
|
582
|
+
__slots__ = ("raw",)
|
|
583
|
+
|
|
584
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@dataclass()
|
|
588
|
+
class APIOperation(Generic[P, R, S]):
|
|
589
|
+
"""An API operation (e.g., `GET /users`)."""
|
|
590
|
+
|
|
591
|
+
# `path` does not contain `basePath`
|
|
592
|
+
# Example <scheme>://<host>/<basePath>/users - "/users" is path
|
|
593
|
+
# https://swagger.io/docs/specification/2-0/api-host-and-base-path/
|
|
594
|
+
path: str
|
|
595
|
+
method: str
|
|
596
|
+
definition: OperationDefinition = field(repr=False)
|
|
597
|
+
schema: BaseSchema
|
|
598
|
+
responses: R
|
|
599
|
+
security: S
|
|
600
|
+
label: str = None # type: ignore[assignment]
|
|
601
|
+
app: Any = None
|
|
602
|
+
base_url: str | None = None
|
|
603
|
+
path_parameters: ParameterSet[P] = field(default_factory=ParameterSet)
|
|
604
|
+
headers: ParameterSet[P] = field(default_factory=ParameterSet)
|
|
605
|
+
cookies: ParameterSet[P] = field(default_factory=ParameterSet)
|
|
606
|
+
query: ParameterSet[P] = field(default_factory=ParameterSet)
|
|
607
|
+
body: PayloadAlternatives[P] = field(default_factory=PayloadAlternatives)
|
|
608
|
+
|
|
609
|
+
def __post_init__(self) -> None:
|
|
610
|
+
if self.label is None:
|
|
611
|
+
self.label = f"{self.method.upper()} {self.path}" # type: ignore[unreachable]
|
|
612
|
+
|
|
613
|
+
def __deepcopy__(self, memo: dict) -> APIOperation[P, R, S]:
|
|
614
|
+
return self
|
|
615
|
+
|
|
616
|
+
def __hash__(self) -> int:
|
|
617
|
+
return hash(self.label)
|
|
618
|
+
|
|
619
|
+
def __eq__(self, value: object, /) -> bool:
|
|
620
|
+
if not isinstance(value, APIOperation):
|
|
621
|
+
return NotImplemented
|
|
622
|
+
return self.label == value.label
|
|
623
|
+
|
|
624
|
+
@property
|
|
625
|
+
def full_path(self) -> str:
|
|
626
|
+
return self.schema.get_full_path(self.path)
|
|
627
|
+
|
|
628
|
+
@property
|
|
629
|
+
def tags(self) -> list[str] | None:
|
|
630
|
+
return self.schema.get_tags(self)
|
|
631
|
+
|
|
632
|
+
def iter_parameters(self) -> Iterator[P]:
|
|
633
|
+
return chain(self.path_parameters, self.headers, self.cookies, self.query)
|
|
634
|
+
|
|
635
|
+
def _lookup_container(self, location: str) -> ParameterSet[P] | PayloadAlternatives[P] | None:
|
|
636
|
+
return {
|
|
637
|
+
"path": self.path_parameters,
|
|
638
|
+
"header": self.headers,
|
|
639
|
+
"cookie": self.cookies,
|
|
640
|
+
"query": self.query,
|
|
641
|
+
"body": self.body,
|
|
642
|
+
}.get(location)
|
|
643
|
+
|
|
644
|
+
def add_parameter(self, parameter: P) -> None:
|
|
645
|
+
# If the parameter has a typo, then by default, there will be an error from `jsonschema` earlier.
|
|
646
|
+
# But if the user wants to skip schema validation, we choose to ignore a malformed parameter.
|
|
647
|
+
# In this case, we still might generate some tests for an API operation, but without this parameter,
|
|
648
|
+
# which is better than skip the whole operation from testing.
|
|
649
|
+
container = self._lookup_container(parameter.location)
|
|
650
|
+
if container is not None:
|
|
651
|
+
container.add(parameter)
|
|
652
|
+
|
|
653
|
+
def get_parameter(self, name: str, location: str) -> P | None:
|
|
654
|
+
container = self._lookup_container(location)
|
|
655
|
+
if container is not None:
|
|
656
|
+
return container.get(name)
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
def get_bodies_for_media_type(self, media_type: str) -> Iterator[P]:
|
|
660
|
+
main_target, sub_target = media_types.parse(media_type)
|
|
661
|
+
for body in self.body:
|
|
662
|
+
main, sub = media_types.parse(body.media_type) # type:ignore[attr-defined]
|
|
663
|
+
if main in ("*", main_target) and sub in ("*", sub_target):
|
|
664
|
+
yield body
|
|
665
|
+
|
|
666
|
+
def as_strategy(
|
|
667
|
+
self,
|
|
668
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
|
669
|
+
**kwargs: Any,
|
|
670
|
+
) -> SearchStrategy[Case]:
|
|
671
|
+
"""Create a Hypothesis strategy that generates test cases for this API operation.
|
|
672
|
+
|
|
673
|
+
Use with `@given` in non-Schemathesis tests.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
generation_mode: Whether to generate positive or negative test data.
|
|
677
|
+
**kwargs: Extra arguments to the underlying strategy function.
|
|
678
|
+
|
|
679
|
+
"""
|
|
680
|
+
if self.schema.config.headers:
|
|
681
|
+
headers = kwargs.setdefault("headers", {})
|
|
682
|
+
headers.update(self.schema.config.headers)
|
|
683
|
+
strategy = self.schema.get_case_strategy(self, generation_mode=generation_mode, **kwargs)
|
|
684
|
+
|
|
685
|
+
def _apply_hooks(dispatcher: HookDispatcher, _strategy: SearchStrategy[Case]) -> SearchStrategy[Case]:
|
|
686
|
+
context = HookContext(operation=self)
|
|
687
|
+
for hook in dispatcher.get_all_by_name("before_generate_case"):
|
|
688
|
+
if _should_skip_hook(hook, context):
|
|
689
|
+
continue
|
|
690
|
+
_strategy = hook(context, _strategy)
|
|
691
|
+
for hook in dispatcher.get_all_by_name("filter_case"):
|
|
692
|
+
if _should_skip_hook(hook, context):
|
|
693
|
+
continue
|
|
694
|
+
hook = partial(hook, context)
|
|
695
|
+
_strategy = _strategy.filter(hook)
|
|
696
|
+
for hook in dispatcher.get_all_by_name("map_case"):
|
|
697
|
+
if _should_skip_hook(hook, context):
|
|
698
|
+
continue
|
|
699
|
+
hook = partial(hook, context)
|
|
700
|
+
_strategy = _strategy.map(hook)
|
|
701
|
+
for hook in dispatcher.get_all_by_name("flatmap_case"):
|
|
702
|
+
if _should_skip_hook(hook, context):
|
|
703
|
+
continue
|
|
704
|
+
hook = partial(hook, context)
|
|
705
|
+
_strategy = _strategy.flatmap(hook)
|
|
706
|
+
return _strategy
|
|
707
|
+
|
|
708
|
+
strategy = _apply_hooks(GLOBAL_HOOK_DISPATCHER, strategy)
|
|
709
|
+
strategy = _apply_hooks(self.schema.hooks, strategy)
|
|
710
|
+
hooks = kwargs.get("hooks")
|
|
711
|
+
if hooks is not None:
|
|
712
|
+
strategy = _apply_hooks(hooks, strategy)
|
|
713
|
+
return strategy
|
|
714
|
+
|
|
715
|
+
def get_strategies_from_examples(self, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
|
716
|
+
return self.schema.get_strategies_from_examples(self, **kwargs)
|
|
717
|
+
|
|
718
|
+
def get_parameter_serializer(self, location: str) -> Callable | None:
|
|
719
|
+
return self.schema.get_parameter_serializer(self, location)
|
|
720
|
+
|
|
721
|
+
def prepare_multipart(self, form_data: dict[str, Any]) -> tuple[list | None, dict[str, Any] | None]:
|
|
722
|
+
return self.schema.prepare_multipart(form_data, self)
|
|
723
|
+
|
|
724
|
+
def get_request_payload_content_types(self) -> list[str]:
|
|
725
|
+
return self.schema.get_request_payload_content_types(self)
|
|
726
|
+
|
|
727
|
+
def _get_default_media_type(self) -> str:
|
|
728
|
+
# If the user wants to send payload, then there should be a media type, otherwise the payload is ignored
|
|
729
|
+
media_types = self.get_request_payload_content_types()
|
|
730
|
+
if len(media_types) == 1:
|
|
731
|
+
# The only available option
|
|
732
|
+
return media_types[0]
|
|
733
|
+
media_types_repr = ", ".join(media_types)
|
|
734
|
+
raise IncorrectUsage(
|
|
735
|
+
"Can not detect appropriate media type. "
|
|
736
|
+
"You can either specify one of the defined media types "
|
|
737
|
+
f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
def validate_response(
|
|
741
|
+
self,
|
|
742
|
+
response: Response | httpx.Response | requests.Response | TestResponse,
|
|
743
|
+
*,
|
|
744
|
+
case: Case | None = None,
|
|
745
|
+
) -> bool | None:
|
|
746
|
+
"""Validate a response against the API schema.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
response: The HTTP response to validate. Can be a `requests.Response`,
|
|
750
|
+
`httpx.Response`, `werkzeug.test.TestResponse`, or `schemathesis.Response`.
|
|
751
|
+
case: The generated test case related to the provided response.
|
|
752
|
+
|
|
753
|
+
Raises:
|
|
754
|
+
FailureGroup: If the response does not conform to the schema.
|
|
755
|
+
|
|
756
|
+
"""
|
|
757
|
+
return self.schema.validate_response(self, Response.from_any(response), case=case)
|
|
758
|
+
|
|
759
|
+
def is_valid_response(self, response: Response | httpx.Response | requests.Response | TestResponse) -> bool:
|
|
760
|
+
"""Check if the provided response is valid against the API schema.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
response: The HTTP response to validate. Can be a `requests.Response`,
|
|
764
|
+
`httpx.Response`, `werkzeug.test.TestResponse`, or `schemathesis.Response`.
|
|
765
|
+
|
|
766
|
+
Returns:
|
|
767
|
+
`True` if response is valid, `False` otherwise.
|
|
768
|
+
|
|
769
|
+
"""
|
|
770
|
+
try:
|
|
771
|
+
self.validate_response(response)
|
|
772
|
+
return True
|
|
773
|
+
except AssertionError:
|
|
774
|
+
return False
|
|
775
|
+
|
|
776
|
+
def Case(
|
|
777
|
+
self,
|
|
778
|
+
*,
|
|
779
|
+
method: str | None = None,
|
|
780
|
+
path_parameters: dict[str, Any] | None = None,
|
|
781
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
|
782
|
+
cookies: dict[str, Any] | None = None,
|
|
783
|
+
query: dict[str, Any] | None = None,
|
|
784
|
+
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
|
785
|
+
media_type: str | None = None,
|
|
786
|
+
_meta: CaseMetadata | None = None,
|
|
787
|
+
) -> Case:
|
|
788
|
+
"""Create a test case with specific data instead of generated values.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
method: Override HTTP method.
|
|
792
|
+
path_parameters: Override path variables.
|
|
793
|
+
headers: Override HTTP headers.
|
|
794
|
+
cookies: Override cookies.
|
|
795
|
+
query: Override query parameters.
|
|
796
|
+
body: Override request body.
|
|
797
|
+
media_type: Override media type.
|
|
798
|
+
|
|
799
|
+
"""
|
|
800
|
+
from requests.structures import CaseInsensitiveDict
|
|
801
|
+
|
|
802
|
+
return self.schema.make_case(
|
|
803
|
+
operation=self,
|
|
804
|
+
method=method,
|
|
805
|
+
path_parameters=path_parameters or {},
|
|
806
|
+
headers=CaseInsensitiveDict() if headers is None else CaseInsensitiveDict(headers),
|
|
807
|
+
cookies=cookies or {},
|
|
808
|
+
query=query or {},
|
|
809
|
+
body=body,
|
|
810
|
+
media_type=media_type,
|
|
811
|
+
meta=_meta,
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
@property
|
|
815
|
+
def operation_reference(self) -> str:
|
|
816
|
+
path = self.path.replace("~", "~0").replace("/", "~1")
|
|
817
|
+
return f"#/paths/{path}/{self.method}"
|