schemathesis 3.39.7__py3-none-any.whl → 4.0.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/__init__.py +27 -65
- schemathesis/auths.py +26 -68
- schemathesis/checks.py +130 -60
- schemathesis/cli/__init__.py +5 -2105
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +30 -0
- schemathesis/cli/commands/run/executor.py +141 -0
- schemathesis/cli/commands/run/filters.py +202 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/{cassettes.py → commands/run/handlers/cassettes.py} +178 -247
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +1368 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +59 -175
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +37 -16
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -7
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/{internal/output.py → core/output/__init__.py} +1 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +243 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +49 -68
- schemathesis/engine/phases/stateful/__init__.py +66 -0
- schemathesis/engine/phases/stateful/_executor.py +301 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +175 -0
- schemathesis/engine/phases/unit/_executor.py +322 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +246 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +9 -40
- schemathesis/filters.py +7 -95
- schemathesis/generation/__init__.py +3 -3
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +22 -22
- schemathesis/{_patches.py → generation/hypothesis/__init__.py} +15 -6
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +84 -109
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +17 -62
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +387 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +94 -107
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +456 -228
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +5 -3
- schemathesis/specs/graphql/schemas.py +122 -123
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +97 -134
- schemathesis/specs/openapi/checks.py +238 -219
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +22 -20
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/nodes.py +33 -32
- schemathesis/specs/openapi/formats.py +3 -2
- schemathesis/specs/openapi/links.py +123 -299
- schemathesis/specs/openapi/media_types.py +10 -12
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +3 -2
- schemathesis/specs/openapi/parameters.py +8 -6
- schemathesis/specs/openapi/patterns.py +1 -1
- schemathesis/specs/openapi/references.py +11 -51
- schemathesis/specs/openapi/schemas.py +177 -191
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +10 -6
- schemathesis/specs/openapi/stateful/__init__.py +97 -91
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -7
- schemathesis/transport/wsgi.py +165 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +18 -14
- schemathesis-4.0.0a2.dist-info/RECORD +151 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -559
- schemathesis/_override.py +0 -50
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -936
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -56
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -277
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -84
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -38
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -104
- schemathesis/runner/impl/core.py +0 -1246
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/loaders.py +0 -708
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -359
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.7.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-3.39.7.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,585 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import json
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from functools import wraps
|
6
|
+
from itertools import combinations
|
7
|
+
from time import perf_counter
|
8
|
+
from typing import Any, Callable, Generator, Mapping
|
9
|
+
|
10
|
+
import hypothesis
|
11
|
+
from hypothesis import Phase
|
12
|
+
from hypothesis import strategies as st
|
13
|
+
from hypothesis.errors import Unsatisfiable
|
14
|
+
from jsonschema.exceptions import SchemaError
|
15
|
+
|
16
|
+
from schemathesis.auths import AuthStorageMark
|
17
|
+
from schemathesis.core import NOT_SET, NotSet, media_types
|
18
|
+
from schemathesis.core.errors import InvalidSchema, SerializationNotPossible
|
19
|
+
from schemathesis.core.marks import Mark
|
20
|
+
from schemathesis.core.result import Ok, Result
|
21
|
+
from schemathesis.core.transport import prepare_urlencoded
|
22
|
+
from schemathesis.core.validation import has_invalid_characters, is_latin_1_encodable
|
23
|
+
from schemathesis.experimental import COVERAGE_PHASE
|
24
|
+
from schemathesis.generation import GenerationConfig, GenerationMode, coverage
|
25
|
+
from schemathesis.generation.case import Case
|
26
|
+
from schemathesis.generation.hypothesis import DEFAULT_DEADLINE, examples, setup, strategies
|
27
|
+
from schemathesis.generation.hypothesis.given import GivenInput
|
28
|
+
from schemathesis.generation.meta import CaseMetadata, CoveragePhaseData, GenerationInfo, PhaseInfo
|
29
|
+
from schemathesis.hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, HookDispatcherMark
|
30
|
+
from schemathesis.schemas import APIOperation, BaseSchema, ParameterSet
|
31
|
+
|
32
|
+
setup()
|
33
|
+
|
34
|
+
|
35
|
+
def get_all_tests(
|
36
|
+
*,
|
37
|
+
schema: BaseSchema,
|
38
|
+
test_func: Callable,
|
39
|
+
generation_config: GenerationConfig,
|
40
|
+
settings: hypothesis.settings | None = None,
|
41
|
+
seed: int | None = None,
|
42
|
+
as_strategy_kwargs: Callable[[APIOperation], dict[str, Any]] | None = None,
|
43
|
+
given_kwargs: dict[str, GivenInput] | None = None,
|
44
|
+
) -> Generator[Result[tuple[APIOperation, Callable], InvalidSchema], None, None]:
|
45
|
+
"""Generate all operations and Hypothesis tests for them."""
|
46
|
+
for result in schema.get_all_operations(generation_config=generation_config):
|
47
|
+
if isinstance(result, Ok):
|
48
|
+
operation = result.ok()
|
49
|
+
if callable(as_strategy_kwargs):
|
50
|
+
_as_strategy_kwargs = as_strategy_kwargs(operation)
|
51
|
+
else:
|
52
|
+
_as_strategy_kwargs = {}
|
53
|
+
test = create_test(
|
54
|
+
operation=operation,
|
55
|
+
test_func=test_func,
|
56
|
+
config=HypothesisTestConfig(
|
57
|
+
settings=settings,
|
58
|
+
seed=seed,
|
59
|
+
generation=generation_config,
|
60
|
+
as_strategy_kwargs=_as_strategy_kwargs,
|
61
|
+
given_kwargs=given_kwargs or {},
|
62
|
+
),
|
63
|
+
)
|
64
|
+
yield Ok((operation, test))
|
65
|
+
else:
|
66
|
+
yield result
|
67
|
+
|
68
|
+
|
69
|
+
@dataclass
|
70
|
+
class HypothesisTestConfig:
|
71
|
+
generation: GenerationConfig
|
72
|
+
settings: hypothesis.settings | None = None
|
73
|
+
seed: int | None = None
|
74
|
+
as_strategy_kwargs: dict[str, Any] = field(default_factory=dict)
|
75
|
+
given_args: tuple[GivenInput, ...] = ()
|
76
|
+
given_kwargs: dict[str, GivenInput] = field(default_factory=dict)
|
77
|
+
|
78
|
+
|
79
|
+
def create_test(
|
80
|
+
*,
|
81
|
+
operation: APIOperation,
|
82
|
+
test_func: Callable,
|
83
|
+
config: HypothesisTestConfig,
|
84
|
+
) -> Callable:
|
85
|
+
"""Create a Hypothesis test."""
|
86
|
+
hook_dispatcher = HookDispatcherMark.get(test_func)
|
87
|
+
auth_storage = AuthStorageMark.get(test_func)
|
88
|
+
|
89
|
+
strategy_kwargs = {
|
90
|
+
"hooks": hook_dispatcher,
|
91
|
+
"auth_storage": auth_storage,
|
92
|
+
"generation_config": config.generation,
|
93
|
+
**config.as_strategy_kwargs,
|
94
|
+
}
|
95
|
+
strategy = strategies.combine(
|
96
|
+
[operation.as_strategy(generation_mode=mode, **strategy_kwargs) for mode in config.generation.modes]
|
97
|
+
)
|
98
|
+
|
99
|
+
hypothesis_test = create_base_test(
|
100
|
+
test_function=test_func,
|
101
|
+
strategy=strategy,
|
102
|
+
args=config.given_args,
|
103
|
+
kwargs=config.given_kwargs,
|
104
|
+
)
|
105
|
+
|
106
|
+
if config.seed is not None:
|
107
|
+
hypothesis_test = hypothesis.seed(config.seed)(hypothesis_test)
|
108
|
+
|
109
|
+
default = hypothesis.settings.default
|
110
|
+
settings = getattr(hypothesis_test, SETTINGS_ATTRIBUTE_NAME, None)
|
111
|
+
assert settings is not None
|
112
|
+
|
113
|
+
if settings.deadline == default.deadline:
|
114
|
+
settings = hypothesis.settings(settings, deadline=DEFAULT_DEADLINE)
|
115
|
+
|
116
|
+
if config.settings is not None:
|
117
|
+
# Merge the user-provided settings with the current ones
|
118
|
+
settings = hypothesis.settings(
|
119
|
+
settings,
|
120
|
+
**{item: value for item, value in config.settings.__dict__.items() if value != getattr(default, item)},
|
121
|
+
)
|
122
|
+
|
123
|
+
if Phase.explain in settings.phases:
|
124
|
+
phases = tuple(phase for phase in settings.phases if phase != Phase.explain)
|
125
|
+
settings = hypothesis.settings(settings, phases=phases)
|
126
|
+
|
127
|
+
# Add examples if explicit phase is enabled
|
128
|
+
if Phase.explicit in settings.phases:
|
129
|
+
hypothesis_test = add_examples(hypothesis_test, operation, hook_dispatcher=hook_dispatcher, **strategy_kwargs)
|
130
|
+
|
131
|
+
if COVERAGE_PHASE.is_enabled:
|
132
|
+
# Ensure explicit phase is enabled if coverage is enabled
|
133
|
+
if Phase.explicit not in settings.phases:
|
134
|
+
phases = settings.phases + (Phase.explicit,)
|
135
|
+
settings = hypothesis.settings(settings, phases=phases)
|
136
|
+
hypothesis_test = add_coverage(hypothesis_test, operation, config.generation.modes)
|
137
|
+
|
138
|
+
setattr(hypothesis_test, SETTINGS_ATTRIBUTE_NAME, settings)
|
139
|
+
|
140
|
+
return hypothesis_test
|
141
|
+
|
142
|
+
|
143
|
+
SETTINGS_ATTRIBUTE_NAME = "_hypothesis_internal_use_settings"
|
144
|
+
|
145
|
+
|
146
|
+
def create_base_test(
|
147
|
+
*,
|
148
|
+
test_function: Callable,
|
149
|
+
strategy: st.SearchStrategy,
|
150
|
+
args: tuple[GivenInput, ...],
|
151
|
+
kwargs: dict[str, GivenInput],
|
152
|
+
) -> Callable:
|
153
|
+
"""Create the basic Hypothesis test with the given strategy."""
|
154
|
+
|
155
|
+
@wraps(test_function)
|
156
|
+
def test_wrapper(*args: Any, **kwargs: Any) -> Any:
|
157
|
+
__tracebackhide__ = True
|
158
|
+
return test_function(*args, **kwargs)
|
159
|
+
|
160
|
+
return hypothesis.given(*args, **{**kwargs, "case": strategy})(test_wrapper)
|
161
|
+
|
162
|
+
|
163
|
+
def add_examples(
|
164
|
+
test: Callable, operation: APIOperation, hook_dispatcher: HookDispatcher | None = None, **kwargs: Any
|
165
|
+
) -> Callable:
|
166
|
+
"""Add examples to the Hypothesis test, if they are specified in the schema."""
|
167
|
+
from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
|
168
|
+
|
169
|
+
try:
|
170
|
+
result: list[Case] = [
|
171
|
+
examples.generate_one(strategy) for strategy in operation.get_strategies_from_examples(**kwargs)
|
172
|
+
]
|
173
|
+
except (
|
174
|
+
InvalidSchema,
|
175
|
+
HypothesisRefResolutionError,
|
176
|
+
Unsatisfiable,
|
177
|
+
SerializationNotPossible,
|
178
|
+
SchemaError,
|
179
|
+
) as exc:
|
180
|
+
result = []
|
181
|
+
if isinstance(exc, Unsatisfiable):
|
182
|
+
UnsatisfiableExampleMark.set(test, exc)
|
183
|
+
if isinstance(exc, SerializationNotPossible):
|
184
|
+
NonSerializableMark.set(test, exc)
|
185
|
+
if isinstance(exc, SchemaError):
|
186
|
+
InvalidRegexMark.set(test, exc)
|
187
|
+
context = HookContext(operation) # context should be passed here instead
|
188
|
+
GLOBAL_HOOK_DISPATCHER.dispatch("before_add_examples", context, result)
|
189
|
+
operation.schema.hooks.dispatch("before_add_examples", context, result)
|
190
|
+
if hook_dispatcher:
|
191
|
+
hook_dispatcher.dispatch("before_add_examples", context, result)
|
192
|
+
original_test = test
|
193
|
+
for example in result:
|
194
|
+
if example.headers is not None:
|
195
|
+
invalid_headers = dict(find_invalid_headers(example.headers))
|
196
|
+
if invalid_headers:
|
197
|
+
InvalidHeadersExampleMark.set(original_test, invalid_headers)
|
198
|
+
continue
|
199
|
+
adjust_urlencoded_payload(example)
|
200
|
+
test = hypothesis.example(case=example)(test)
|
201
|
+
return test
|
202
|
+
|
203
|
+
|
204
|
+
def adjust_urlencoded_payload(case: Case) -> None:
|
205
|
+
if case.media_type is not None:
|
206
|
+
try:
|
207
|
+
media_type = media_types.parse(case.media_type)
|
208
|
+
if media_type == ("application", "x-www-form-urlencoded"):
|
209
|
+
case.body = prepare_urlencoded(case.body)
|
210
|
+
except ValueError:
|
211
|
+
pass
|
212
|
+
|
213
|
+
|
214
|
+
def add_coverage(test: Callable, operation: APIOperation, generation_modes: list[GenerationMode]) -> Callable:
|
215
|
+
for example in _iter_coverage_cases(operation, generation_modes):
|
216
|
+
adjust_urlencoded_payload(example)
|
217
|
+
test = hypothesis.example(case=example)(test)
|
218
|
+
return test
|
219
|
+
|
220
|
+
|
221
|
+
class Instant:
|
222
|
+
__slots__ = ("start",)
|
223
|
+
|
224
|
+
def __init__(self) -> None:
|
225
|
+
self.start = perf_counter()
|
226
|
+
|
227
|
+
@property
|
228
|
+
def elapsed(self) -> float:
|
229
|
+
return perf_counter() - self.start
|
230
|
+
|
231
|
+
|
232
|
+
def _iter_coverage_cases(
|
233
|
+
operation: APIOperation, generation_modes: list[GenerationMode]
|
234
|
+
) -> Generator[Case, None, None]:
|
235
|
+
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
236
|
+
from schemathesis.specs.openapi.examples import find_in_responses, find_matching_in_responses
|
237
|
+
|
238
|
+
def _stringify_value(val: Any, location: str) -> str | list[str]:
|
239
|
+
if isinstance(val, list):
|
240
|
+
if location == "query":
|
241
|
+
# Having a list here ensures there will be multiple query parameters wit the same name
|
242
|
+
return [json.dumps(item) for item in val]
|
243
|
+
# use comma-separated values style for arrays
|
244
|
+
return ",".join(json.dumps(sub) for sub in val)
|
245
|
+
return json.dumps(val)
|
246
|
+
|
247
|
+
generators: dict[tuple[str, str], Generator[coverage.GeneratedValue, None, None]] = {}
|
248
|
+
template: dict[str, Any] = {}
|
249
|
+
|
250
|
+
instant = Instant()
|
251
|
+
responses = find_in_responses(operation)
|
252
|
+
for parameter in operation.iter_parameters():
|
253
|
+
location = parameter.location
|
254
|
+
name = parameter.name
|
255
|
+
schema = parameter.as_json_schema(operation, update_quantifiers=False)
|
256
|
+
for value in find_matching_in_responses(responses, parameter.name):
|
257
|
+
schema.setdefault("examples", []).append(value)
|
258
|
+
gen = coverage.cover_schema_iter(
|
259
|
+
coverage.CoverageContext(location=location, generation_modes=generation_modes), schema
|
260
|
+
)
|
261
|
+
value = next(gen, NOT_SET)
|
262
|
+
if isinstance(value, NotSet):
|
263
|
+
continue
|
264
|
+
container = template.setdefault(LOCATION_TO_CONTAINER[location], {})
|
265
|
+
if location in ("header", "cookie", "path", "query") and not isinstance(value.value, str):
|
266
|
+
container[name] = _stringify_value(value.value, location)
|
267
|
+
else:
|
268
|
+
container[name] = value.value
|
269
|
+
generators[(location, name)] = gen
|
270
|
+
template_time = instant.elapsed
|
271
|
+
if operation.body:
|
272
|
+
for body in operation.body:
|
273
|
+
instant = Instant()
|
274
|
+
schema = body.as_json_schema(operation, update_quantifiers=False)
|
275
|
+
# Definition could be a list for Open API 2.0
|
276
|
+
definition = body.definition if isinstance(body.definition, dict) else {}
|
277
|
+
examples = [example["value"] for example in definition.get("examples", {}).values() if "value" in example]
|
278
|
+
if examples:
|
279
|
+
schema.setdefault("examples", []).extend(examples)
|
280
|
+
gen = coverage.cover_schema_iter(
|
281
|
+
coverage.CoverageContext(location="body", generation_modes=generation_modes), schema
|
282
|
+
)
|
283
|
+
value = next(gen, NOT_SET)
|
284
|
+
if isinstance(value, NotSet):
|
285
|
+
continue
|
286
|
+
elapsed = instant.elapsed
|
287
|
+
if "body" not in template:
|
288
|
+
template_time += elapsed
|
289
|
+
template["body"] = value.value
|
290
|
+
template["media_type"] = body.media_type
|
291
|
+
yield operation.Case(
|
292
|
+
**{**template, "body": value.value, "media_type": body.media_type},
|
293
|
+
meta=CaseMetadata(
|
294
|
+
generation=GenerationInfo(
|
295
|
+
time=elapsed,
|
296
|
+
mode=value.generation_mode,
|
297
|
+
),
|
298
|
+
components={},
|
299
|
+
phase=PhaseInfo.coverage(
|
300
|
+
description=value.description,
|
301
|
+
location=value.location,
|
302
|
+
parameter=body.media_type,
|
303
|
+
parameter_location="body",
|
304
|
+
),
|
305
|
+
),
|
306
|
+
)
|
307
|
+
iterator = iter(gen)
|
308
|
+
while True:
|
309
|
+
instant = Instant()
|
310
|
+
try:
|
311
|
+
next_value = next(iterator)
|
312
|
+
yield operation.Case(
|
313
|
+
**{**template, "body": next_value.value, "media_type": body.media_type},
|
314
|
+
meta=CaseMetadata(
|
315
|
+
generation=GenerationInfo(
|
316
|
+
time=instant.elapsed,
|
317
|
+
mode=value.generation_mode,
|
318
|
+
),
|
319
|
+
components={},
|
320
|
+
phase=PhaseInfo.coverage(
|
321
|
+
description=next_value.description,
|
322
|
+
location=next_value.location,
|
323
|
+
parameter=body.media_type,
|
324
|
+
parameter_location="body",
|
325
|
+
),
|
326
|
+
),
|
327
|
+
)
|
328
|
+
except StopIteration:
|
329
|
+
break
|
330
|
+
elif GenerationMode.POSITIVE in generation_modes:
|
331
|
+
yield operation.Case(
|
332
|
+
**template,
|
333
|
+
meta=CaseMetadata(
|
334
|
+
generation=GenerationInfo(
|
335
|
+
time=template_time,
|
336
|
+
mode=GenerationMode.POSITIVE,
|
337
|
+
),
|
338
|
+
components={},
|
339
|
+
phase=PhaseInfo.coverage(description="Default positive test case"),
|
340
|
+
),
|
341
|
+
)
|
342
|
+
|
343
|
+
for (location, name), gen in generators.items():
|
344
|
+
container_name = LOCATION_TO_CONTAINER[location]
|
345
|
+
container = template[container_name]
|
346
|
+
iterator = iter(gen)
|
347
|
+
while True:
|
348
|
+
instant = Instant()
|
349
|
+
try:
|
350
|
+
value = next(iterator)
|
351
|
+
if location in ("header", "cookie", "path", "query") and not isinstance(value.value, str):
|
352
|
+
generated = _stringify_value(value.value, location)
|
353
|
+
else:
|
354
|
+
generated = value.value
|
355
|
+
except StopIteration:
|
356
|
+
break
|
357
|
+
yield operation.Case(
|
358
|
+
**{**template, container_name: {**container, name: generated}},
|
359
|
+
meta=CaseMetadata(
|
360
|
+
generation=GenerationInfo(time=instant.elapsed, mode=value.generation_mode),
|
361
|
+
components={},
|
362
|
+
phase=PhaseInfo.coverage(
|
363
|
+
description=value.description,
|
364
|
+
location=value.location,
|
365
|
+
parameter=name,
|
366
|
+
parameter_location=location,
|
367
|
+
),
|
368
|
+
),
|
369
|
+
)
|
370
|
+
if GenerationMode.NEGATIVE in generation_modes:
|
371
|
+
# Generate HTTP methods that are not specified in the spec
|
372
|
+
methods = {"get", "put", "post", "delete", "options", "patch", "trace"} - set(operation.schema[operation.path])
|
373
|
+
for method in sorted(methods):
|
374
|
+
instant = Instant()
|
375
|
+
yield operation.Case(
|
376
|
+
**template,
|
377
|
+
method=method.upper(),
|
378
|
+
meta=CaseMetadata(
|
379
|
+
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
380
|
+
components={},
|
381
|
+
phase=PhaseInfo.coverage(description=f"Unspecified HTTP method: {method.upper()}"),
|
382
|
+
),
|
383
|
+
)
|
384
|
+
# Generate duplicate query parameters
|
385
|
+
if operation.query:
|
386
|
+
container = template["query"]
|
387
|
+
for parameter in operation.query:
|
388
|
+
instant = Instant()
|
389
|
+
value = container[parameter.name]
|
390
|
+
yield operation.Case(
|
391
|
+
**{**template, "query": {**container, parameter.name: [value, value]}},
|
392
|
+
meta=CaseMetadata(
|
393
|
+
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
394
|
+
components={},
|
395
|
+
phase=PhaseInfo.coverage(
|
396
|
+
description=f"Duplicate `{parameter.name}` query parameter",
|
397
|
+
parameter=parameter.name,
|
398
|
+
parameter_location="query",
|
399
|
+
),
|
400
|
+
),
|
401
|
+
)
|
402
|
+
# Generate missing required parameters
|
403
|
+
for parameter in operation.iter_parameters():
|
404
|
+
if parameter.is_required and parameter.location != "path":
|
405
|
+
instant = Instant()
|
406
|
+
name = parameter.name
|
407
|
+
location = parameter.location
|
408
|
+
container_name = LOCATION_TO_CONTAINER[location]
|
409
|
+
container = template[container_name]
|
410
|
+
yield operation.Case(
|
411
|
+
**{**template, container_name: {k: v for k, v in container.items() if k != name}},
|
412
|
+
meta=CaseMetadata(
|
413
|
+
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
414
|
+
components={},
|
415
|
+
phase=PhaseInfo.coverage(
|
416
|
+
description=f"Missing `{name}` at {location}",
|
417
|
+
parameter=name,
|
418
|
+
parameter_location=location,
|
419
|
+
),
|
420
|
+
),
|
421
|
+
)
|
422
|
+
# Generate combinations for each location
|
423
|
+
for location, parameter_set in [
|
424
|
+
("query", operation.query),
|
425
|
+
("header", operation.headers),
|
426
|
+
("cookie", operation.cookies),
|
427
|
+
]:
|
428
|
+
if not parameter_set:
|
429
|
+
continue
|
430
|
+
|
431
|
+
container_name = LOCATION_TO_CONTAINER[location]
|
432
|
+
base_container = template.get(container_name, {})
|
433
|
+
|
434
|
+
# Get required and optional parameters
|
435
|
+
required = {p.name for p in parameter_set if p.is_required}
|
436
|
+
all_params = {p.name for p in parameter_set}
|
437
|
+
optional = sorted(all_params - required)
|
438
|
+
|
439
|
+
# Helper function to create and yield a case
|
440
|
+
def make_case(
|
441
|
+
container_values: dict,
|
442
|
+
description: str,
|
443
|
+
_location: str,
|
444
|
+
_container_name: str,
|
445
|
+
_parameter: str | None,
|
446
|
+
_generation_mode: GenerationMode,
|
447
|
+
_instant: Instant,
|
448
|
+
) -> Case:
|
449
|
+
if _location in ("header", "cookie", "path", "query"):
|
450
|
+
container = {
|
451
|
+
name: _stringify_value(val, _location) if not isinstance(val, str) else val
|
452
|
+
for name, val in container_values.items()
|
453
|
+
}
|
454
|
+
else:
|
455
|
+
container = container_values
|
456
|
+
|
457
|
+
return operation.Case(
|
458
|
+
**{**template, _container_name: container},
|
459
|
+
meta=CaseMetadata(
|
460
|
+
generation=GenerationInfo(
|
461
|
+
time=_instant.elapsed,
|
462
|
+
mode=_generation_mode,
|
463
|
+
),
|
464
|
+
components={},
|
465
|
+
phase=PhaseInfo.coverage(
|
466
|
+
description=description,
|
467
|
+
parameter=_parameter,
|
468
|
+
parameter_location=_location,
|
469
|
+
),
|
470
|
+
),
|
471
|
+
)
|
472
|
+
|
473
|
+
def _combination_schema(
|
474
|
+
combination: dict[str, Any], _required: set[str], _parameter_set: ParameterSet
|
475
|
+
) -> dict[str, Any]:
|
476
|
+
return {
|
477
|
+
"properties": {
|
478
|
+
parameter.name: parameter.as_json_schema(operation)
|
479
|
+
for parameter in _parameter_set
|
480
|
+
if parameter.name in combination
|
481
|
+
},
|
482
|
+
"required": list(_required),
|
483
|
+
"additionalProperties": False,
|
484
|
+
}
|
485
|
+
|
486
|
+
def _yield_negative(
|
487
|
+
subschema: dict[str, Any], _location: str, _container_name: str
|
488
|
+
) -> Generator[Case, None, None]:
|
489
|
+
iterator = iter(
|
490
|
+
coverage.cover_schema_iter(
|
491
|
+
coverage.CoverageContext(location=_location, generation_modes=[GenerationMode.NEGATIVE]),
|
492
|
+
subschema,
|
493
|
+
)
|
494
|
+
)
|
495
|
+
while True:
|
496
|
+
instant = Instant()
|
497
|
+
try:
|
498
|
+
more = next(iterator)
|
499
|
+
yield make_case(
|
500
|
+
more.value,
|
501
|
+
more.description,
|
502
|
+
_location,
|
503
|
+
_container_name,
|
504
|
+
more.parameter,
|
505
|
+
GenerationMode.NEGATIVE,
|
506
|
+
instant,
|
507
|
+
)
|
508
|
+
except StopIteration:
|
509
|
+
break
|
510
|
+
|
511
|
+
# 1. Generate only required properties
|
512
|
+
if required and all_params != required:
|
513
|
+
only_required = {k: v for k, v in base_container.items() if k in required}
|
514
|
+
if GenerationMode.POSITIVE in generation_modes:
|
515
|
+
yield make_case(
|
516
|
+
only_required,
|
517
|
+
"Only required properties",
|
518
|
+
location,
|
519
|
+
container_name,
|
520
|
+
None,
|
521
|
+
GenerationMode.POSITIVE,
|
522
|
+
Instant(),
|
523
|
+
)
|
524
|
+
if GenerationMode.NEGATIVE in generation_modes:
|
525
|
+
subschema = _combination_schema(only_required, required, parameter_set)
|
526
|
+
for case in _yield_negative(subschema, location, container_name):
|
527
|
+
assert case.meta is not None
|
528
|
+
assert isinstance(case.meta.phase.data, CoveragePhaseData)
|
529
|
+
# Already generated in one of the blocks above
|
530
|
+
if location != "path" and not case.meta.phase.data.description.startswith(
|
531
|
+
"Missing required property"
|
532
|
+
):
|
533
|
+
yield case
|
534
|
+
|
535
|
+
# 2. Generate combinations with required properties and one optional property
|
536
|
+
for opt_param in optional:
|
537
|
+
combo = {k: v for k, v in base_container.items() if k in required or k == opt_param}
|
538
|
+
if combo != base_container and GenerationMode.POSITIVE in generation_modes:
|
539
|
+
yield make_case(
|
540
|
+
combo,
|
541
|
+
f"All required properties and optional '{opt_param}'",
|
542
|
+
location,
|
543
|
+
container_name,
|
544
|
+
None,
|
545
|
+
GenerationMode.POSITIVE,
|
546
|
+
Instant(),
|
547
|
+
)
|
548
|
+
if GenerationMode.NEGATIVE in generation_modes:
|
549
|
+
subschema = _combination_schema(combo, required, parameter_set)
|
550
|
+
for case in _yield_negative(subschema, location, container_name):
|
551
|
+
assert case.meta is not None
|
552
|
+
assert isinstance(case.meta.phase.data, CoveragePhaseData)
|
553
|
+
# Already generated in one of the blocks above
|
554
|
+
if location != "path" and not case.meta.phase.data.description.startswith(
|
555
|
+
"Missing required property"
|
556
|
+
):
|
557
|
+
yield case
|
558
|
+
|
559
|
+
# 3. Generate one combination for each size from 2 to N-1 of optional parameters
|
560
|
+
if len(optional) > 1 and GenerationMode.POSITIVE in generation_modes:
|
561
|
+
for size in range(2, len(optional)):
|
562
|
+
for combination in combinations(optional, size):
|
563
|
+
combo = {k: v for k, v in base_container.items() if k in required or k in combination}
|
564
|
+
if combo != base_container:
|
565
|
+
yield make_case(
|
566
|
+
combo,
|
567
|
+
f"All required and {size} optional properties",
|
568
|
+
location,
|
569
|
+
container_name,
|
570
|
+
None,
|
571
|
+
GenerationMode.POSITIVE,
|
572
|
+
Instant(),
|
573
|
+
)
|
574
|
+
|
575
|
+
|
576
|
+
def find_invalid_headers(headers: Mapping) -> Generator[tuple[str, str], None, None]:
|
577
|
+
for name, value in headers.items():
|
578
|
+
if not is_latin_1_encodable(value) or has_invalid_characters(name, value):
|
579
|
+
yield name, value
|
580
|
+
|
581
|
+
|
582
|
+
UnsatisfiableExampleMark = Mark[Unsatisfiable](attr_name="unsatisfiable_example")
|
583
|
+
NonSerializableMark = Mark[SerializationNotPossible](attr_name="non_serializable")
|
584
|
+
InvalidRegexMark = Mark[SchemaError](attr_name="invalid_regex")
|
585
|
+
InvalidHeadersExampleMark = Mark[dict[str, str]](attr_name="invalid_example_header")
|
@@ -1,8 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import os
|
4
|
-
from functools import lru_cache
|
5
|
-
from operator import or_
|
4
|
+
from functools import lru_cache
|
6
5
|
from typing import TYPE_CHECKING, TypeVar
|
7
6
|
|
8
7
|
if TYPE_CHECKING:
|
@@ -29,7 +28,7 @@ def default_settings() -> settings:
|
|
29
28
|
T = TypeVar("T")
|
30
29
|
|
31
30
|
|
32
|
-
def
|
31
|
+
def generate_one(strategy: st.SearchStrategy[T]) -> T: # type: ignore[type-var]
|
33
32
|
examples: list[T] = []
|
34
33
|
add_single_example(strategy, examples)
|
35
34
|
return examples[0]
|
@@ -49,11 +48,3 @@ def add_single_example(strategy: st.SearchStrategy[T], examples: list[T]) -> Non
|
|
49
48
|
example_generating_inner_function = seed(SCHEMATHESIS_BENCHMARK_SEED)(example_generating_inner_function)
|
50
49
|
|
51
50
|
example_generating_inner_function()
|
52
|
-
|
53
|
-
|
54
|
-
def combine_strategies(strategies: list[st.SearchStrategy] | tuple[st.SearchStrategy]) -> st.SearchStrategy:
|
55
|
-
"""Combine a list of strategies into a single one.
|
56
|
-
|
57
|
-
If the input is `[a, b, c]`, then the result is equivalent to `a | b | c`.
|
58
|
-
"""
|
59
|
-
return reduce(or_, strategies[1:], strategies[0])
|
@@ -0,0 +1,66 @@
|
|
1
|
+
"""Integrating `hypothesis.given` into Schemathesis."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from inspect import getfullargspec
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, NoReturn, Union
|
7
|
+
|
8
|
+
from schemathesis.core.errors import IncorrectUsage
|
9
|
+
from schemathesis.core.marks import Mark
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
from hypothesis.strategies import SearchStrategy
|
13
|
+
|
14
|
+
|
15
|
+
__all__ = ["is_given_applied", "given_proxy", "merge_given_args", "GivenInput", "GivenArgsMark", "GivenKwargsMark"]
|
16
|
+
|
17
|
+
EllipsisType = type(...)
|
18
|
+
GivenInput = Union["SearchStrategy", EllipsisType] # type: ignore[valid-type]
|
19
|
+
|
20
|
+
GivenArgsMark = Mark[tuple](attr_name="given_args", default=())
|
21
|
+
GivenKwargsMark = Mark[dict[str, Any]](attr_name="given_kwargs", default=dict)
|
22
|
+
|
23
|
+
|
24
|
+
def is_given_applied(func: Callable) -> bool:
|
25
|
+
return GivenArgsMark.is_set(func) or GivenKwargsMark.is_set(func)
|
26
|
+
|
27
|
+
|
28
|
+
def given_proxy(*args: GivenInput, **kwargs: GivenInput) -> Callable[[Callable], Callable]:
|
29
|
+
"""Proxy Hypothesis strategies to ``hypothesis.given``."""
|
30
|
+
|
31
|
+
def wrapper(func: Callable) -> Callable:
|
32
|
+
if is_given_applied(func):
|
33
|
+
|
34
|
+
def wrapped_test(*_: Any, **__: Any) -> NoReturn:
|
35
|
+
raise IncorrectUsage(
|
36
|
+
f"You have applied `given` to the `{func.__name__}` test more than once, which "
|
37
|
+
"overrides the previous decorator. You need to pass all arguments to the same `given` call."
|
38
|
+
)
|
39
|
+
|
40
|
+
return wrapped_test
|
41
|
+
|
42
|
+
GivenArgsMark.set(func, args)
|
43
|
+
GivenKwargsMark.set(func, kwargs)
|
44
|
+
return func
|
45
|
+
|
46
|
+
return wrapper
|
47
|
+
|
48
|
+
|
49
|
+
def merge_given_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> dict[str, Any]:
|
50
|
+
"""Merge positional arguments to ``@schema.given`` into a dictionary with keyword arguments.
|
51
|
+
|
52
|
+
Kwargs are modified inplace.
|
53
|
+
"""
|
54
|
+
if args:
|
55
|
+
argspec = getfullargspec(func)
|
56
|
+
for name, strategy in zip(reversed([arg for arg in argspec.args if arg != "case"]), reversed(args)):
|
57
|
+
kwargs[name] = strategy
|
58
|
+
return kwargs
|
59
|
+
|
60
|
+
|
61
|
+
def validate_given_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> Callable | None:
|
62
|
+
from hypothesis.core import is_invalid_test
|
63
|
+
from hypothesis.internal.reflection import get_signature
|
64
|
+
|
65
|
+
signature = get_signature(func)
|
66
|
+
return is_invalid_test(func, signature, args, kwargs) # type: ignore
|
@@ -0,0 +1,14 @@
|
|
1
|
+
from contextlib import contextmanager
|
2
|
+
from typing import Generator
|
3
|
+
|
4
|
+
from hypothesis.reporting import with_reporter
|
5
|
+
|
6
|
+
|
7
|
+
def ignore(_: str) -> None:
|
8
|
+
pass
|
9
|
+
|
10
|
+
|
11
|
+
@contextmanager
|
12
|
+
def ignore_hypothesis_output() -> Generator:
|
13
|
+
with with_reporter(ignore): # type: ignore
|
14
|
+
yield
|