schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a12__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 +29 -30
- schemathesis/auths.py +65 -24
- schemathesis/checks.py +73 -39
- schemathesis/cli/commands/__init__.py +51 -3
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +163 -274
- schemathesis/cli/commands/run/context.py +8 -4
- schemathesis/cli/commands/run/events.py +11 -1
- schemathesis/cli/commands/run/executor.py +70 -78
- schemathesis/cli/commands/run/filters.py +15 -165
- schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
- schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
- schemathesis/cli/commands/run/handlers/output.py +195 -121
- schemathesis/cli/commands/run/loaders.py +35 -50
- schemathesis/cli/commands/run/validation.py +52 -162
- schemathesis/cli/core.py +5 -3
- schemathesis/cli/ext/fs.py +7 -5
- schemathesis/cli/ext/options.py +0 -21
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +523 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +2 -0
- schemathesis/core/compat.py +16 -9
- schemathesis/core/errors.py +24 -4
- schemathesis/core/failures.py +6 -7
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/output/__init__.py +14 -37
- schemathesis/core/output/sanitization.py +3 -146
- schemathesis/core/transport.py +36 -1
- schemathesis/core/validation.py +16 -0
- schemathesis/engine/__init__.py +2 -4
- schemathesis/engine/context.py +42 -43
- schemathesis/engine/core.py +7 -5
- schemathesis/engine/errors.py +60 -1
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/__init__.py +10 -0
- schemathesis/engine/phases/probes.py +11 -8
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +104 -46
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/__init__.py +23 -15
- schemathesis/engine/phases/unit/_executor.py +110 -21
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/errors.py +2 -0
- schemathesis/filters.py +2 -3
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +6 -3
- schemathesis/generation/coverage.py +154 -124
- schemathesis/generation/hypothesis/builder.py +70 -20
- schemathesis/generation/meta.py +3 -3
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +37 -1
- schemathesis/generation/stateful/__init__.py +4 -0
- schemathesis/generation/stateful/state_machine.py +9 -1
- schemathesis/graphql/loaders.py +159 -16
- schemathesis/hooks.py +62 -35
- schemathesis/openapi/checks.py +12 -8
- schemathesis/openapi/generation/filters.py +10 -8
- schemathesis/openapi/loaders.py +142 -17
- schemathesis/pytest/lazy.py +2 -5
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +33 -2
- schemathesis/schemas.py +21 -66
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +23 -18
- schemathesis/specs/openapi/_hypothesis.py +26 -28
- schemathesis/specs/openapi/checks.py +37 -36
- schemathesis/specs/openapi/examples.py +4 -3
- schemathesis/specs/openapi/formats.py +32 -5
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/negative/__init__.py +2 -2
- schemathesis/specs/openapi/patterns.py +46 -16
- schemathesis/specs/openapi/references.py +2 -3
- schemathesis/specs/openapi/schemas.py +19 -22
- schemathesis/specs/openapi/stateful/__init__.py +12 -6
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +38 -13
- schemathesis/transport/requests.py +12 -9
- schemathesis/transport/wsgi.py +11 -12
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +50 -97
- schemathesis-4.0.0a12.dist-info/RECORD +164 -0
- schemathesis/cli/commands/run/checks.py +0 -79
- schemathesis/cli/commands/run/hypothesis.py +0 -78
- schemathesis/cli/commands/run/reports.py +0 -72
- schemathesis/cli/hooks.py +0 -36
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/engine/config.py +0 -59
- schemathesis/experimental/__init__.py +0 -72
- schemathesis/generation/targets.py +0 -69
- schemathesis-4.0.0a10.dist-info/RECORD +0 -153
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/licenses/LICENSE +0 -0
schemathesis/pytest/lazy.py
CHANGED
@@ -12,7 +12,6 @@ from pytest_subtests import SubTests
|
|
12
12
|
from schemathesis.core.errors import InvalidSchema
|
13
13
|
from schemathesis.core.result import Ok, Result
|
14
14
|
from schemathesis.filters import FilterSet, FilterValue, MatcherFunc, RegexValue, is_deprecated
|
15
|
-
from schemathesis.generation import GenerationConfig
|
16
15
|
from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, HypothesisTestMode, create_test
|
17
16
|
from schemathesis.generation.hypothesis.given import (
|
18
17
|
GivenArgsMark,
|
@@ -38,7 +37,6 @@ def get_all_tests(
|
|
38
37
|
*,
|
39
38
|
schema: BaseSchema,
|
40
39
|
test_func: Callable,
|
41
|
-
generation_config: GenerationConfig,
|
42
40
|
modes: list[HypothesisTestMode],
|
43
41
|
settings: hypothesis.settings | None = None,
|
44
42
|
seed: int | None = None,
|
@@ -46,7 +44,7 @@ def get_all_tests(
|
|
46
44
|
given_kwargs: dict[str, GivenInput] | None = None,
|
47
45
|
) -> Generator[Result[tuple[APIOperation, Callable], InvalidSchema], None, None]:
|
48
46
|
"""Generate all operations and Hypothesis tests for them."""
|
49
|
-
for result in schema.get_all_operations(
|
47
|
+
for result in schema.get_all_operations():
|
50
48
|
if isinstance(result, Ok):
|
51
49
|
operation = result.ok()
|
52
50
|
if callable(as_strategy_kwargs):
|
@@ -60,7 +58,7 @@ def get_all_tests(
|
|
60
58
|
settings=settings,
|
61
59
|
modes=modes,
|
62
60
|
seed=seed,
|
63
|
-
|
61
|
+
project=schema.config,
|
64
62
|
as_strategy_kwargs=_as_strategy_kwargs,
|
65
63
|
given_kwargs=given_kwargs or {},
|
66
64
|
),
|
@@ -194,7 +192,6 @@ class LazySchema:
|
|
194
192
|
test_func=test_func,
|
195
193
|
settings=settings,
|
196
194
|
modes=list(HypothesisTestMode),
|
197
|
-
generation_config=schema.generation_config,
|
198
195
|
as_strategy_kwargs=as_strategy_kwargs,
|
199
196
|
given_kwargs=given_kwargs,
|
200
197
|
)
|
schemathesis/pytest/loaders.py
CHANGED
@@ -7,6 +7,30 @@ if TYPE_CHECKING:
|
|
7
7
|
|
8
8
|
|
9
9
|
def from_fixture(name: str) -> LazySchema:
|
10
|
+
"""Create a lazy schema loader that resolves a pytest fixture at test runtime.
|
11
|
+
|
12
|
+
Args:
|
13
|
+
name: Name of the pytest fixture that returns a schema object
|
14
|
+
|
15
|
+
Example:
|
16
|
+
```python
|
17
|
+
import pytest
|
18
|
+
import schemathesis
|
19
|
+
|
20
|
+
@pytest.fixture
|
21
|
+
def api_schema():
|
22
|
+
return schemathesis.openapi.from_url("https://api.example.com/openapi.json")
|
23
|
+
|
24
|
+
# Create lazy schema from fixture
|
25
|
+
schema = schemathesis.pytest.from_fixture("api_schema")
|
26
|
+
|
27
|
+
# Use with parametrize to generate tests
|
28
|
+
@schema.parametrize()
|
29
|
+
def test_api(case):
|
30
|
+
case.call_and_validate()
|
31
|
+
```
|
32
|
+
|
33
|
+
"""
|
10
34
|
from schemathesis.pytest.lazy import LazySchema
|
11
35
|
|
12
36
|
return LazySchema(name)
|
schemathesis/pytest/plugin.py
CHANGED
@@ -21,7 +21,9 @@ from schemathesis.core.errors import (
|
|
21
21
|
InvalidRegexPattern,
|
22
22
|
InvalidSchema,
|
23
23
|
SerializationNotPossible,
|
24
|
+
format_exception,
|
24
25
|
)
|
26
|
+
from schemathesis.core.failures import FailureGroup
|
25
27
|
from schemathesis.core.marks import Mark
|
26
28
|
from schemathesis.core.result import Ok, Result
|
27
29
|
from schemathesis.generation.hypothesis.given import (
|
@@ -134,13 +136,22 @@ class SchemathesisCase(PyCollector):
|
|
134
136
|
as_strategy_kwargs[location] = entry
|
135
137
|
else:
|
136
138
|
as_strategy_kwargs = {}
|
139
|
+
modes = []
|
140
|
+
phases = self.schema.config.phases_for(operation=operation)
|
141
|
+
if phases.examples.enabled:
|
142
|
+
modes.append(HypothesisTestMode.EXAMPLES)
|
143
|
+
if phases.fuzzing.enabled:
|
144
|
+
modes.append(HypothesisTestMode.FUZZING)
|
145
|
+
if phases.coverage.enabled:
|
146
|
+
modes.append(HypothesisTestMode.COVERAGE)
|
147
|
+
|
137
148
|
funcobj = create_test(
|
138
149
|
operation=operation,
|
139
150
|
test_func=self.test_function,
|
140
151
|
config=HypothesisTestConfig(
|
141
|
-
modes=
|
152
|
+
modes=modes,
|
142
153
|
given_kwargs=self.given_kwargs,
|
143
|
-
|
154
|
+
project=self.schema.config,
|
144
155
|
as_strategy_kwargs=as_strategy_kwargs,
|
145
156
|
),
|
146
157
|
)
|
@@ -238,6 +249,26 @@ def pytest_pycollect_makeitem(collector: nodes.Collector, name: str, obj: Any) -
|
|
238
249
|
outcome.get_result()
|
239
250
|
|
240
251
|
|
252
|
+
@pytest.hookimpl(tryfirst=True) # type: ignore[misc]
|
253
|
+
def pytest_exception_interact(node: Function, call: pytest.CallInfo, report: pytest.TestReport) -> None:
|
254
|
+
if call.excinfo and call.excinfo.type is FailureGroup:
|
255
|
+
tb_entries = list(call.excinfo.traceback)
|
256
|
+
total_frames = len(tb_entries)
|
257
|
+
|
258
|
+
# Keep internal Schemathesis frames + one extra one from the caller
|
259
|
+
keep_from_index = 0
|
260
|
+
for i in range(total_frames - 1, -1, -1):
|
261
|
+
entry = tb_entries[i]
|
262
|
+
|
263
|
+
if "validate_response" in str(entry):
|
264
|
+
keep_from_index = max(0, i - 1)
|
265
|
+
break
|
266
|
+
|
267
|
+
skip_frames = keep_from_index
|
268
|
+
|
269
|
+
report.longrepr = "".join(format_exception(call.excinfo.value, with_traceback=True, skip_frames=skip_frames))
|
270
|
+
|
271
|
+
|
241
272
|
@hookimpl(wrapper=True)
|
242
273
|
def pytest_pyfunc_call(pyfuncitem): # type:ignore
|
243
274
|
"""It is possible to have a Hypothesis exception in runtime.
|
schemathesis/schemas.py
CHANGED
@@ -14,16 +14,15 @@ from typing import (
|
|
14
14
|
NoReturn,
|
15
15
|
TypeVar,
|
16
16
|
)
|
17
|
-
from urllib.parse import quote, unquote, urljoin,
|
17
|
+
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
18
18
|
|
19
19
|
from schemathesis import transport
|
20
|
+
from schemathesis.config import ProjectConfig
|
20
21
|
from schemathesis.core import NOT_SET, NotSet
|
21
22
|
from schemathesis.core.errors import IncorrectUsage, InvalidSchema
|
22
|
-
from schemathesis.core.output import OutputConfig
|
23
|
-
from schemathesis.core.rate_limit import build_limiter
|
24
23
|
from schemathesis.core.result import Ok, Result
|
25
24
|
from schemathesis.core.transport import Response
|
26
|
-
from schemathesis.generation import
|
25
|
+
from schemathesis.generation import GenerationMode
|
27
26
|
from schemathesis.generation.case import Case
|
28
27
|
from schemathesis.generation.hypothesis import strategies
|
29
28
|
from schemathesis.generation.hypothesis.given import GivenInput, given_proxy
|
@@ -42,7 +41,6 @@ from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, HookScop
|
|
42
41
|
|
43
42
|
if TYPE_CHECKING:
|
44
43
|
from hypothesis.strategies import SearchStrategy
|
45
|
-
from pyrate_limiter import Limiter
|
46
44
|
from typing_extensions import Self
|
47
45
|
|
48
46
|
from schemathesis.core import Specification
|
@@ -102,16 +100,13 @@ class ApiOperationsCount:
|
|
102
100
|
@dataclass(eq=False)
|
103
101
|
class BaseSchema(Mapping):
|
104
102
|
raw_schema: dict[str, Any]
|
103
|
+
config: ProjectConfig
|
105
104
|
location: str | None = None
|
106
|
-
base_url: str | None = None
|
107
105
|
filter_set: FilterSet = field(default_factory=FilterSet)
|
108
106
|
app: Any = None
|
109
107
|
hooks: HookDispatcher = field(default_factory=lambda: HookDispatcher(scope=HookScope.SCHEMA))
|
110
108
|
auth: AuthStorage = field(default_factory=AuthStorage)
|
111
109
|
test_function: Callable | None = None
|
112
|
-
generation_config: GenerationConfig = field(default_factory=GenerationConfig)
|
113
|
-
output_config: OutputConfig = field(default_factory=OutputConfig)
|
114
|
-
rate_limiter: Limiter | None = None
|
115
110
|
|
116
111
|
def __post_init__(self) -> None:
|
117
112
|
self.hook = to_filterable_hook(self.hooks) # type: ignore[method-assign]
|
@@ -216,7 +211,7 @@ class BaseSchema(Mapping):
|
|
216
211
|
return self.statistic.operations.total
|
217
212
|
|
218
213
|
def hook(self, hook: str | Callable) -> Callable:
|
219
|
-
return self.hooks.
|
214
|
+
return self.hooks.hook(hook)
|
220
215
|
|
221
216
|
def get_full_path(self, path: str) -> str:
|
222
217
|
"""Compute full path for the given path."""
|
@@ -227,8 +222,8 @@ class BaseSchema(Mapping):
|
|
227
222
|
"""Base path for the schema."""
|
228
223
|
# if `base_url` is specified, then it should include base path
|
229
224
|
# Example: http://127.0.0.1:8080/api
|
230
|
-
if self.base_url:
|
231
|
-
path = urlsplit(self.base_url).path
|
225
|
+
if self.config.base_url:
|
226
|
+
path = urlsplit(self.config.base_url).path
|
232
227
|
else:
|
233
228
|
path = self._get_base_path()
|
234
229
|
if not path.endswith("/"):
|
@@ -244,7 +239,7 @@ class BaseSchema(Mapping):
|
|
244
239
|
return urlunsplit(parts)
|
245
240
|
|
246
241
|
def get_base_url(self) -> str:
|
247
|
-
base_url = self.base_url
|
242
|
+
base_url = self.config.base_url
|
248
243
|
if base_url is not None:
|
249
244
|
return base_url.rstrip("/")
|
250
245
|
return self._build_base_url()
|
@@ -259,9 +254,7 @@ class BaseSchema(Mapping):
|
|
259
254
|
def _measure_statistic(self) -> ApiStatistic:
|
260
255
|
raise NotImplementedError
|
261
256
|
|
262
|
-
def get_all_operations(
|
263
|
-
self, generation_config: GenerationConfig | None = None
|
264
|
-
) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
257
|
+
def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
265
258
|
raise NotImplementedError
|
266
259
|
|
267
260
|
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
@@ -317,15 +310,12 @@ class BaseSchema(Mapping):
|
|
317
310
|
|
318
311
|
return self.__class__(
|
319
312
|
self.raw_schema,
|
313
|
+
config=self.config,
|
320
314
|
location=self.location,
|
321
|
-
base_url=self.base_url,
|
322
315
|
app=self.app,
|
323
316
|
hooks=self.hooks,
|
324
317
|
auth=self.auth,
|
325
318
|
test_function=_test_function,
|
326
|
-
generation_config=self.generation_config,
|
327
|
-
output_config=self.output_config,
|
328
|
-
rate_limiter=self.rate_limiter,
|
329
319
|
filter_set=_filter_set,
|
330
320
|
)
|
331
321
|
|
@@ -378,8 +368,7 @@ class BaseSchema(Mapping):
|
|
378
368
|
operation: APIOperation,
|
379
369
|
hooks: HookDispatcher | None = None,
|
380
370
|
auth_storage: AuthStorage | None = None,
|
381
|
-
generation_mode: GenerationMode = GenerationMode.
|
382
|
-
generation_config: GenerationConfig | None = None,
|
371
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
383
372
|
**kwargs: Any,
|
384
373
|
) -> SearchStrategy:
|
385
374
|
raise NotImplementedError
|
@@ -407,8 +396,7 @@ class BaseSchema(Mapping):
|
|
407
396
|
self,
|
408
397
|
hooks: HookDispatcher | None = None,
|
409
398
|
auth_storage: AuthStorage | None = None,
|
410
|
-
generation_mode: GenerationMode = GenerationMode.
|
411
|
-
generation_config: GenerationConfig | None = None,
|
399
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
412
400
|
**kwargs: Any,
|
413
401
|
) -> SearchStrategy:
|
414
402
|
"""Build a strategy for generating test cases for all defined API operations."""
|
@@ -417,7 +405,6 @@ class BaseSchema(Mapping):
|
|
417
405
|
hooks=hooks,
|
418
406
|
auth_storage=auth_storage,
|
419
407
|
generation_mode=generation_mode,
|
420
|
-
generation_config=generation_config,
|
421
408
|
**kwargs,
|
422
409
|
)
|
423
410
|
for operation in self.get_all_operations()
|
@@ -428,46 +415,17 @@ class BaseSchema(Mapping):
|
|
428
415
|
def configure(
|
429
416
|
self,
|
430
417
|
*,
|
431
|
-
base_url: str | None | NotSet = NOT_SET,
|
432
418
|
location: str | None | NotSet = NOT_SET,
|
433
|
-
rate_limit: str | None | NotSet = NOT_SET,
|
434
|
-
generation: GenerationConfig | NotSet = NOT_SET,
|
435
|
-
output: OutputConfig | NotSet = NOT_SET,
|
436
419
|
app: Any | NotSet = NOT_SET,
|
437
420
|
) -> Self:
|
438
|
-
if not isinstance(base_url, NotSet):
|
439
|
-
if base_url is not None:
|
440
|
-
validate_base_url(base_url)
|
441
|
-
self.base_url = base_url
|
442
421
|
if not isinstance(location, NotSet):
|
443
422
|
self.location = location
|
444
|
-
if not isinstance(rate_limit, NotSet):
|
445
|
-
if isinstance(rate_limit, str):
|
446
|
-
self.rate_limiter = build_limiter(rate_limit)
|
447
|
-
else:
|
448
|
-
self.rate_limiter = None
|
449
|
-
if not isinstance(generation, NotSet):
|
450
|
-
self.generation_config = generation
|
451
|
-
if not isinstance(output, NotSet):
|
452
|
-
self.output_config = output
|
453
423
|
if not isinstance(app, NotSet):
|
454
424
|
self.app = app
|
455
425
|
return self
|
456
426
|
|
457
|
-
|
458
|
-
|
459
|
-
"The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
|
460
|
-
"Make sure it is a properly formatted URL."
|
461
|
-
)
|
462
|
-
|
463
|
-
|
464
|
-
def validate_base_url(value: str) -> None:
|
465
|
-
try:
|
466
|
-
netloc = urlparse(value).netloc
|
467
|
-
except ValueError as exc:
|
468
|
-
raise ValueError(INVALID_BASE_URL_MESSAGE) from exc
|
469
|
-
if value and not netloc:
|
470
|
-
raise ValueError(INVALID_BASE_URL_MESSAGE)
|
427
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
428
|
+
raise NotImplementedError
|
471
429
|
|
472
430
|
|
473
431
|
@dataclass
|
@@ -488,8 +446,7 @@ class APIOperationMap(Mapping):
|
|
488
446
|
self,
|
489
447
|
hooks: HookDispatcher | None = None,
|
490
448
|
auth_storage: AuthStorage | None = None,
|
491
|
-
generation_mode: GenerationMode = GenerationMode.
|
492
|
-
generation_config: GenerationConfig | None = None,
|
449
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
493
450
|
**kwargs: Any,
|
494
451
|
) -> SearchStrategy:
|
495
452
|
"""Build a strategy for generating test cases for all API operations defined in this subset."""
|
@@ -498,7 +455,6 @@ class APIOperationMap(Mapping):
|
|
498
455
|
hooks=hooks,
|
499
456
|
auth_storage=auth_storage,
|
500
457
|
generation_mode=generation_mode,
|
501
|
-
generation_config=generation_config,
|
502
458
|
**kwargs,
|
503
459
|
)
|
504
460
|
for operation in self._data.values()
|
@@ -638,6 +594,9 @@ class APIOperation(Generic[P]):
|
|
638
594
|
if self.label is None:
|
639
595
|
self.label = f"{self.method.upper()} {self.path}" # type: ignore
|
640
596
|
|
597
|
+
def __deepcopy__(self, memo: dict) -> APIOperation[P]:
|
598
|
+
return self
|
599
|
+
|
641
600
|
@property
|
642
601
|
def full_path(self) -> str:
|
643
602
|
return self.schema.get_full_path(self.path)
|
@@ -687,17 +646,14 @@ class APIOperation(Generic[P]):
|
|
687
646
|
self,
|
688
647
|
hooks: HookDispatcher | None = None,
|
689
648
|
auth_storage: AuthStorage | None = None,
|
690
|
-
generation_mode: GenerationMode = GenerationMode.
|
691
|
-
generation_config: GenerationConfig | None = None,
|
649
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
692
650
|
**kwargs: Any,
|
693
651
|
) -> SearchStrategy[Case]:
|
694
652
|
"""Turn this API operation into a Hypothesis strategy."""
|
695
|
-
strategy = self.schema.get_case_strategy(
|
696
|
-
self, hooks, auth_storage, generation_mode, generation_config=generation_config, **kwargs
|
697
|
-
)
|
653
|
+
strategy = self.schema.get_case_strategy(self, hooks, auth_storage, generation_mode, **kwargs)
|
698
654
|
|
699
655
|
def _apply_hooks(dispatcher: HookDispatcher, _strategy: SearchStrategy[Case]) -> SearchStrategy[Case]:
|
700
|
-
context = HookContext(self)
|
656
|
+
context = HookContext(operation=self)
|
701
657
|
for hook in dispatcher.get_all_by_name("before_generate_case"):
|
702
658
|
_strategy = hook(context, _strategy)
|
703
659
|
for hook in dispatcher.get_all_by_name("filter_case"):
|
@@ -722,7 +678,6 @@ class APIOperation(Generic[P]):
|
|
722
678
|
|
723
679
|
def get_strategies_from_examples(self, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
724
680
|
"""Get examples from the API operation."""
|
725
|
-
kwargs.setdefault("generation_config", self.schema.generation_config)
|
726
681
|
return self.schema.get_strategies_from_examples(self, **kwargs)
|
727
682
|
|
728
683
|
def get_parameter_serializer(self, location: str) -> Callable | None:
|
@@ -13,10 +13,44 @@ CUSTOM_SCALARS: dict[str, st.SearchStrategy[graphql.ValueNode]] = {}
|
|
13
13
|
|
14
14
|
|
15
15
|
def scalar(name: str, strategy: st.SearchStrategy[graphql.ValueNode]) -> None:
|
16
|
-
"""Register a
|
16
|
+
r"""Register a custom Hypothesis strategy for generating GraphQL scalar values.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
name: Scalar name that matches your GraphQL schema scalar definition
|
20
|
+
strategy: Hypothesis strategy that generates GraphQL AST ValueNode objects
|
21
|
+
|
22
|
+
Example:
|
23
|
+
```python
|
24
|
+
import schemathesis
|
25
|
+
from hypothesis import strategies as st
|
26
|
+
from schemathesis.graphql import nodes
|
27
|
+
|
28
|
+
# Register email scalar
|
29
|
+
schemathesis.graphql.scalar("Email", st.emails().map(nodes.String))
|
30
|
+
|
31
|
+
# Register positive integer scalar
|
32
|
+
schemathesis.graphql.scalar(
|
33
|
+
"PositiveInt",
|
34
|
+
st.integers(min_value=1).map(nodes.Int)
|
35
|
+
)
|
36
|
+
|
37
|
+
# Register phone number scalar
|
38
|
+
schemathesis.graphql.scalar(
|
39
|
+
"Phone",
|
40
|
+
st.from_regex(r"\+1-\d{3}-\d{3}-\d{4}").map(nodes.String)
|
41
|
+
)
|
42
|
+
```
|
43
|
+
|
44
|
+
Schema usage:
|
45
|
+
```graphql
|
46
|
+
scalar Email
|
47
|
+
scalar PositiveInt
|
48
|
+
|
49
|
+
type Query {
|
50
|
+
getUser(email: Email!, rating: PositiveInt!): User
|
51
|
+
}
|
52
|
+
```
|
17
53
|
|
18
|
-
:param str name: Scalar name. It should correspond the one used in the schema.
|
19
|
-
:param strategy: Hypothesis strategy you'd like to use to generate values for this scalar.
|
20
54
|
"""
|
21
55
|
from hypothesis.strategies import SearchStrategy
|
22
56
|
|
@@ -28,7 +28,7 @@ from schemathesis import auths
|
|
28
28
|
from schemathesis.core import NOT_SET, NotSet, Specification
|
29
29
|
from schemathesis.core.errors import InvalidSchema, OperationNotFound
|
30
30
|
from schemathesis.core.result import Ok, Result
|
31
|
-
from schemathesis.generation import
|
31
|
+
from schemathesis.generation import GenerationMode
|
32
32
|
from schemathesis.generation.case import Case
|
33
33
|
from schemathesis.generation.meta import (
|
34
34
|
CaseMetadata,
|
@@ -115,6 +115,15 @@ class GraphQLSchema(BaseSchema):
|
|
115
115
|
return map
|
116
116
|
raise KeyError(key)
|
117
117
|
|
118
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
119
|
+
if label.startswith(("Query.", "Mutation.")):
|
120
|
+
ty, field = label.split(".", maxsplit=1)
|
121
|
+
try:
|
122
|
+
return self[ty][field]
|
123
|
+
except KeyError:
|
124
|
+
return None
|
125
|
+
return None
|
126
|
+
|
118
127
|
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
119
128
|
raw_schema = self.raw_schema["__schema"]
|
120
129
|
type_names = [type_def["name"] for type_def in raw_schema.get("types", [])]
|
@@ -139,8 +148,8 @@ class GraphQLSchema(BaseSchema):
|
|
139
148
|
|
140
149
|
@property
|
141
150
|
def base_path(self) -> str:
|
142
|
-
if self.base_url:
|
143
|
-
return urlsplit(self.base_url).path
|
151
|
+
if self.config.base_url:
|
152
|
+
return urlsplit(self.config.base_url).path
|
144
153
|
return self._get_base_path()
|
145
154
|
|
146
155
|
def _get_base_path(self) -> str:
|
@@ -171,9 +180,7 @@ class GraphQLSchema(BaseSchema):
|
|
171
180
|
statistic.operations.selected += 1
|
172
181
|
return statistic
|
173
182
|
|
174
|
-
def get_all_operations(
|
175
|
-
self, generation_config: GenerationConfig | None = None
|
176
|
-
) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
183
|
+
def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
177
184
|
schema = self.client_schema
|
178
185
|
for root_type, operation_type in (
|
179
186
|
(RootType.QUERY, schema.query_type),
|
@@ -225,8 +232,7 @@ class GraphQLSchema(BaseSchema):
|
|
225
232
|
operation: APIOperation,
|
226
233
|
hooks: HookDispatcher | None = None,
|
227
234
|
auth_storage: AuthStorage | None = None,
|
228
|
-
generation_mode: GenerationMode = GenerationMode.
|
229
|
-
generation_config: GenerationConfig | None = None,
|
235
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
230
236
|
**kwargs: Any,
|
231
237
|
) -> SearchStrategy:
|
232
238
|
return graphql_cases(
|
@@ -234,7 +240,6 @@ class GraphQLSchema(BaseSchema):
|
|
234
240
|
hooks=hooks,
|
235
241
|
auth_storage=auth_storage,
|
236
242
|
generation_mode=generation_mode,
|
237
|
-
generation_config=generation_config or self.generation_config,
|
238
243
|
**kwargs,
|
239
244
|
)
|
240
245
|
|
@@ -325,15 +330,14 @@ def graphql_cases(
|
|
325
330
|
operation: APIOperation,
|
326
331
|
hooks: HookDispatcher | None = None,
|
327
332
|
auth_storage: auths.AuthStorage | None = None,
|
328
|
-
generation_mode: GenerationMode = GenerationMode.
|
329
|
-
generation_config: GenerationConfig,
|
333
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
330
334
|
path_parameters: NotSet | dict[str, Any] = NOT_SET,
|
331
335
|
headers: NotSet | dict[str, Any] = NOT_SET,
|
332
336
|
cookies: NotSet | dict[str, Any] = NOT_SET,
|
333
337
|
query: NotSet | dict[str, Any] = NOT_SET,
|
334
338
|
body: Any = NOT_SET,
|
335
339
|
media_type: str | None = None,
|
336
|
-
phase: TestPhase = TestPhase.
|
340
|
+
phase: TestPhase = TestPhase.FUZZING,
|
337
341
|
) -> Any:
|
338
342
|
start = time.monotonic()
|
339
343
|
definition = cast(GraphQLOperationDefinition, operation.definition)
|
@@ -341,16 +345,17 @@ def graphql_cases(
|
|
341
345
|
RootType.QUERY: gql_st.queries,
|
342
346
|
RootType.MUTATION: gql_st.mutations,
|
343
347
|
}[definition.root_type]
|
344
|
-
hook_context = HookContext(operation)
|
348
|
+
hook_context = HookContext(operation=operation)
|
345
349
|
custom_scalars = {**get_extra_scalar_strategies(), **CUSTOM_SCALARS}
|
350
|
+
generation = operation.schema.config.generation_for(operation=operation, phase="fuzzing")
|
346
351
|
strategy = strategy_factory(
|
347
352
|
operation.schema.client_schema, # type: ignore[attr-defined]
|
348
353
|
fields=[definition.field_name],
|
349
354
|
custom_scalars=custom_scalars,
|
350
355
|
print_ast=_noop, # type: ignore
|
351
|
-
allow_x00=
|
352
|
-
allow_null=
|
353
|
-
codec=
|
356
|
+
allow_x00=generation.allow_x00,
|
357
|
+
allow_null=generation.graphql_allow_null,
|
358
|
+
codec=generation.codec,
|
354
359
|
)
|
355
360
|
strategy = apply_to_all_dispatchers(operation, hook_context, hooks, strategy, "body").map(graphql.print_ast)
|
356
361
|
body = draw(strategy)
|
@@ -361,8 +366,8 @@ def graphql_cases(
|
|
361
366
|
query_ = _generate_parameter("query", query, draw, operation, hook_context, hooks)
|
362
367
|
|
363
368
|
_phase_data = {
|
364
|
-
TestPhase.
|
365
|
-
TestPhase.
|
369
|
+
TestPhase.EXAMPLES: ExplicitPhaseData(),
|
370
|
+
TestPhase.FUZZING: GeneratePhaseData(),
|
366
371
|
}[phase]
|
367
372
|
phase_data = cast(Union[ExplicitPhaseData, GeneratePhaseData], _phase_data)
|
368
373
|
instance = operation.Case(
|
@@ -11,6 +11,7 @@ from hypothesis import event, note, reject
|
|
11
11
|
from hypothesis import strategies as st
|
12
12
|
from hypothesis_jsonschema import from_schema
|
13
13
|
|
14
|
+
from schemathesis.config import GenerationConfig
|
14
15
|
from schemathesis.core import NOT_SET, NotSet, media_types
|
15
16
|
from schemathesis.core.control import SkipTest
|
16
17
|
from schemathesis.core.errors import SERIALIZERS_SUGGESTION_MESSAGE, SerializationNotPossible
|
@@ -30,7 +31,7 @@ from schemathesis.openapi.generation.filters import is_valid_header, is_valid_pa
|
|
30
31
|
from schemathesis.schemas import APIOperation
|
31
32
|
|
32
33
|
from ... import auths
|
33
|
-
from ...generation import
|
34
|
+
from ...generation import GenerationMode
|
34
35
|
from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
35
36
|
from .constants import LOCATION_TO_CONTAINER
|
36
37
|
from .formats import HEADER_FORMAT, STRING_FORMATS, get_default_format_strategies, header_values
|
@@ -51,15 +52,15 @@ def openapi_cases(
|
|
51
52
|
operation: APIOperation,
|
52
53
|
hooks: HookDispatcher | None = None,
|
53
54
|
auth_storage: auths.AuthStorage | None = None,
|
54
|
-
generation_mode: GenerationMode = GenerationMode.
|
55
|
-
generation_config: GenerationConfig,
|
55
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
56
56
|
path_parameters: NotSet | dict[str, Any] = NOT_SET,
|
57
57
|
headers: NotSet | dict[str, Any] = NOT_SET,
|
58
58
|
cookies: NotSet | dict[str, Any] = NOT_SET,
|
59
59
|
query: NotSet | dict[str, Any] = NOT_SET,
|
60
60
|
body: Any = NOT_SET,
|
61
61
|
media_type: str | None = None,
|
62
|
-
phase: TestPhase = TestPhase.
|
62
|
+
phase: TestPhase = TestPhase.FUZZING,
|
63
|
+
__is_stateful_phase: bool = False,
|
63
64
|
) -> Any:
|
64
65
|
"""A strategy that creates `Case` instances.
|
65
66
|
|
@@ -76,18 +77,17 @@ def openapi_cases(
|
|
76
77
|
start = time.monotonic()
|
77
78
|
strategy_factory = GENERATOR_MODE_TO_STRATEGY_FACTORY[generation_mode]
|
78
79
|
|
79
|
-
|
80
|
+
phase_name = "stateful" if __is_stateful_phase else phase.value
|
81
|
+
generation_config = operation.schema.config.generation_for(operation=operation, phase=phase_name)
|
82
|
+
|
83
|
+
ctx = HookContext(operation=operation)
|
80
84
|
|
81
85
|
path_parameters_ = generate_parameter(
|
82
|
-
"path", path_parameters, operation, draw,
|
83
|
-
)
|
84
|
-
headers_ = generate_parameter(
|
85
|
-
"header", headers, operation, draw, context, hooks, generation_mode, generation_config
|
86
|
+
"path", path_parameters, operation, draw, ctx, hooks, generation_mode, generation_config
|
86
87
|
)
|
87
|
-
|
88
|
-
|
89
|
-
)
|
90
|
-
query_ = generate_parameter("query", query, operation, draw, context, hooks, generation_mode, generation_config)
|
88
|
+
headers_ = generate_parameter("header", headers, operation, draw, ctx, hooks, generation_mode, generation_config)
|
89
|
+
cookies_ = generate_parameter("cookie", cookies, operation, draw, ctx, hooks, generation_mode, generation_config)
|
90
|
+
query_ = generate_parameter("query", query, operation, draw, ctx, hooks, generation_mode, generation_config)
|
91
91
|
|
92
92
|
if body is NOT_SET:
|
93
93
|
if operation.body:
|
@@ -104,7 +104,7 @@ def openapi_cases(
|
|
104
104
|
candidates = operation.body.items
|
105
105
|
parameter = draw(st.sampled_from(candidates))
|
106
106
|
strategy = _get_body_strategy(parameter, strategy_factory, operation, generation_config)
|
107
|
-
strategy = apply_hooks(operation,
|
107
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, "body")
|
108
108
|
# Parameter may have a wildcard media type. In this case, choose any supported one
|
109
109
|
possible_media_types = sorted(
|
110
110
|
operation.schema.transport.get_matching_media_types(parameter.media_type), key=lambda x: x[0]
|
@@ -147,8 +147,8 @@ def openapi_cases(
|
|
147
147
|
reject()
|
148
148
|
|
149
149
|
_phase_data = {
|
150
|
-
TestPhase.
|
151
|
-
TestPhase.
|
150
|
+
TestPhase.EXAMPLES: ExplicitPhaseData(),
|
151
|
+
TestPhase.FUZZING: GeneratePhaseData(),
|
152
152
|
}[phase]
|
153
153
|
phase_data = cast(Union[ExplicitPhaseData, GeneratePhaseData], _phase_data)
|
154
154
|
|
@@ -215,7 +215,7 @@ def get_parameters_value(
|
|
215
215
|
location: str,
|
216
216
|
draw: Callable,
|
217
217
|
operation: APIOperation,
|
218
|
-
|
218
|
+
ctx: HookContext,
|
219
219
|
hooks: HookDispatcher | None,
|
220
220
|
strategy_factory: StrategyFactory,
|
221
221
|
generation_config: GenerationConfig,
|
@@ -227,10 +227,10 @@ def get_parameters_value(
|
|
227
227
|
"""
|
228
228
|
if isinstance(value, NotSet) or not value:
|
229
229
|
strategy = get_parameters_strategy(operation, strategy_factory, location, generation_config)
|
230
|
-
strategy = apply_hooks(operation,
|
230
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, location)
|
231
231
|
return draw(strategy)
|
232
232
|
strategy = get_parameters_strategy(operation, strategy_factory, location, generation_config, exclude=value.keys())
|
233
|
-
strategy = apply_hooks(operation,
|
233
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, location)
|
234
234
|
new = draw(strategy)
|
235
235
|
if new is not None:
|
236
236
|
copied = deepclone(value)
|
@@ -268,7 +268,7 @@ def generate_parameter(
|
|
268
268
|
explicit: NotSet | dict[str, Any],
|
269
269
|
operation: APIOperation,
|
270
270
|
draw: Callable,
|
271
|
-
|
271
|
+
ctx: HookContext,
|
272
272
|
hooks: HookDispatcher | None,
|
273
273
|
generator: GenerationMode,
|
274
274
|
generation_config: GenerationConfig,
|
@@ -287,9 +287,7 @@ def generate_parameter(
|
|
287
287
|
generator = GenerationMode.POSITIVE
|
288
288
|
else:
|
289
289
|
strategy_factory = GENERATOR_MODE_TO_STRATEGY_FACTORY[generator]
|
290
|
-
value = get_parameters_value(
|
291
|
-
explicit, location, draw, operation, context, hooks, strategy_factory, generation_config
|
292
|
-
)
|
290
|
+
value = get_parameters_value(explicit, location, draw, operation, ctx, hooks, strategy_factory, generation_config)
|
293
291
|
used_generator: GenerationMode | None = generator
|
294
292
|
if value == explicit:
|
295
293
|
# When we pass `explicit`, then its parts are excluded from generation of the final value
|
@@ -407,10 +405,10 @@ def _build_custom_formats(
|
|
407
405
|
custom_formats: dict[str, st.SearchStrategy] | None, generation_config: GenerationConfig
|
408
406
|
) -> dict[str, st.SearchStrategy]:
|
409
407
|
custom_formats = {**get_default_format_strategies(), **STRING_FORMATS, **(custom_formats or {})}
|
410
|
-
if generation_config.
|
411
|
-
custom_formats[HEADER_FORMAT] = generation_config.
|
408
|
+
if generation_config.exclude_header_characters is not None:
|
409
|
+
custom_formats[HEADER_FORMAT] = header_values(exclude_characters=generation_config.exclude_header_characters)
|
412
410
|
elif not generation_config.allow_x00:
|
413
|
-
custom_formats[HEADER_FORMAT] = header_values(
|
411
|
+
custom_formats[HEADER_FORMAT] = header_values(exclude_characters="\n\r\x00")
|
414
412
|
return custom_formats
|
415
413
|
|
416
414
|
|
@@ -490,11 +488,11 @@ def quote_all(parameters: dict[str, Any]) -> dict[str, Any]:
|
|
490
488
|
|
491
489
|
def apply_hooks(
|
492
490
|
operation: APIOperation,
|
493
|
-
|
491
|
+
ctx: HookContext,
|
494
492
|
hooks: HookDispatcher | None,
|
495
493
|
strategy: st.SearchStrategy,
|
496
494
|
location: str,
|
497
495
|
) -> st.SearchStrategy:
|
498
496
|
"""Apply all hooks related to the given location."""
|
499
497
|
container = LOCATION_TO_CONTAINER[location]
|
500
|
-
return apply_to_all_dispatchers(operation,
|
498
|
+
return apply_to_all_dispatchers(operation, ctx, hooks, strategy, container)
|