schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/__init__.py +3 -7
- schemathesis/checks.py +17 -7
- schemathesis/cli/commands/__init__.py +51 -3
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +147 -260
- schemathesis/cli/commands/run/context.py +2 -3
- schemathesis/cli/commands/run/events.py +4 -0
- schemathesis/cli/commands/run/executor.py +60 -73
- 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 +26 -47
- schemathesis/cli/commands/run/loaders.py +35 -50
- schemathesis/cli/commands/run/validation.py +36 -161
- schemathesis/cli/core.py +5 -3
- schemathesis/cli/ext/fs.py +7 -5
- schemathesis/cli/ext/options.py +0 -21
- schemathesis/config/__init__.py +188 -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 +150 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +313 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +151 -0
- schemathesis/config/_projects.py +495 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +116 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/schema.json +837 -0
- schemathesis/core/__init__.py +2 -0
- schemathesis/core/compat.py +16 -9
- schemathesis/core/errors.py +19 -2
- 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/validation.py +16 -0
- schemathesis/engine/__init__.py +2 -4
- schemathesis/engine/context.py +41 -43
- schemathesis/engine/core.py +7 -5
- schemathesis/engine/phases/__init__.py +10 -0
- schemathesis/engine/phases/probes.py +8 -8
- schemathesis/engine/phases/stateful/_executor.py +68 -43
- schemathesis/engine/phases/unit/__init__.py +23 -15
- schemathesis/engine/phases/unit/_executor.py +77 -17
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/errors.py +2 -0
- schemathesis/filters.py +2 -3
- schemathesis/generation/__init__.py +6 -31
- schemathesis/generation/case.py +5 -3
- schemathesis/generation/coverage.py +153 -123
- schemathesis/generation/hypothesis/builder.py +40 -14
- schemathesis/generation/meta.py +3 -3
- schemathesis/generation/overrides.py +37 -1
- schemathesis/generation/stateful/state_machine.py +8 -1
- schemathesis/graphql/loaders.py +21 -12
- schemathesis/openapi/checks.py +12 -8
- schemathesis/openapi/generation/filters.py +10 -8
- schemathesis/openapi/loaders.py +22 -13
- schemathesis/pytest/lazy.py +2 -5
- schemathesis/pytest/plugin.py +11 -2
- schemathesis/schemas.py +13 -61
- schemathesis/specs/graphql/schemas.py +11 -15
- schemathesis/specs/openapi/_hypothesis.py +12 -8
- schemathesis/specs/openapi/checks.py +16 -18
- schemathesis/specs/openapi/examples.py +4 -3
- schemathesis/specs/openapi/formats.py +2 -2
- 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 +11 -20
- schemathesis/specs/openapi/stateful/__init__.py +10 -5
- schemathesis/transport/prepare.py +7 -6
- schemathesis/transport/requests.py +3 -1
- schemathesis/transport/wsgi.py +3 -4
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/METADATA +7 -8
- schemathesis-4.0.0a11.dist-info/RECORD +166 -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/engine/config.py +0 -59
- schemathesis/experimental/__init__.py +0 -72
- schemathesis-4.0.0a10.dist-info/RECORD +0 -153
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/licenses/LICENSE +0 -0
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]
|
@@ -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
|
|
@@ -379,7 +369,6 @@ class BaseSchema(Mapping):
|
|
379
369
|
hooks: HookDispatcher | None = None,
|
380
370
|
auth_storage: AuthStorage | None = None,
|
381
371
|
generation_mode: GenerationMode = GenerationMode.default(),
|
382
|
-
generation_config: GenerationConfig | None = None,
|
383
372
|
**kwargs: Any,
|
384
373
|
) -> SearchStrategy:
|
385
374
|
raise NotImplementedError
|
@@ -408,7 +397,6 @@ class BaseSchema(Mapping):
|
|
408
397
|
hooks: HookDispatcher | None = None,
|
409
398
|
auth_storage: AuthStorage | None = None,
|
410
399
|
generation_mode: GenerationMode = GenerationMode.default(),
|
411
|
-
generation_config: GenerationConfig | None = None,
|
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,48 +415,16 @@ 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
427
|
|
458
|
-
INVALID_BASE_URL_MESSAGE = (
|
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)
|
471
|
-
|
472
|
-
|
473
428
|
@dataclass
|
474
429
|
class APIOperationMap(Mapping):
|
475
430
|
_schema: BaseSchema
|
@@ -489,7 +444,6 @@ class APIOperationMap(Mapping):
|
|
489
444
|
hooks: HookDispatcher | None = None,
|
490
445
|
auth_storage: AuthStorage | None = None,
|
491
446
|
generation_mode: GenerationMode = GenerationMode.default(),
|
492
|
-
generation_config: GenerationConfig | None = None,
|
493
447
|
**kwargs: Any,
|
494
448
|
) -> SearchStrategy:
|
495
449
|
"""Build a strategy for generating test cases for all API operations defined in this subset."""
|
@@ -498,7 +452,6 @@ class APIOperationMap(Mapping):
|
|
498
452
|
hooks=hooks,
|
499
453
|
auth_storage=auth_storage,
|
500
454
|
generation_mode=generation_mode,
|
501
|
-
generation_config=generation_config,
|
502
455
|
**kwargs,
|
503
456
|
)
|
504
457
|
for operation in self._data.values()
|
@@ -638,6 +591,9 @@ class APIOperation(Generic[P]):
|
|
638
591
|
if self.label is None:
|
639
592
|
self.label = f"{self.method.upper()} {self.path}" # type: ignore
|
640
593
|
|
594
|
+
def __deepcopy__(self, memo: dict) -> APIOperation[P]:
|
595
|
+
return self
|
596
|
+
|
641
597
|
@property
|
642
598
|
def full_path(self) -> str:
|
643
599
|
return self.schema.get_full_path(self.path)
|
@@ -688,13 +644,10 @@ class APIOperation(Generic[P]):
|
|
688
644
|
hooks: HookDispatcher | None = None,
|
689
645
|
auth_storage: AuthStorage | None = None,
|
690
646
|
generation_mode: GenerationMode = GenerationMode.default(),
|
691
|
-
generation_config: GenerationConfig | None = None,
|
692
647
|
**kwargs: Any,
|
693
648
|
) -> SearchStrategy[Case]:
|
694
649
|
"""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
|
-
)
|
650
|
+
strategy = self.schema.get_case_strategy(self, hooks, auth_storage, generation_mode, **kwargs)
|
698
651
|
|
699
652
|
def _apply_hooks(dispatcher: HookDispatcher, _strategy: SearchStrategy[Case]) -> SearchStrategy[Case]:
|
700
653
|
context = HookContext(self)
|
@@ -722,7 +675,6 @@ class APIOperation(Generic[P]):
|
|
722
675
|
|
723
676
|
def get_strategies_from_examples(self, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
724
677
|
"""Get examples from the API operation."""
|
725
|
-
kwargs.setdefault("generation_config", self.schema.generation_config)
|
726
678
|
return self.schema.get_strategies_from_examples(self, **kwargs)
|
727
679
|
|
728
680
|
def get_parameter_serializer(self, location: str) -> Callable | None:
|
@@ -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,
|
@@ -139,8 +139,8 @@ class GraphQLSchema(BaseSchema):
|
|
139
139
|
|
140
140
|
@property
|
141
141
|
def base_path(self) -> str:
|
142
|
-
if self.base_url:
|
143
|
-
return urlsplit(self.base_url).path
|
142
|
+
if self.config.base_url:
|
143
|
+
return urlsplit(self.config.base_url).path
|
144
144
|
return self._get_base_path()
|
145
145
|
|
146
146
|
def _get_base_path(self) -> str:
|
@@ -171,9 +171,7 @@ class GraphQLSchema(BaseSchema):
|
|
171
171
|
statistic.operations.selected += 1
|
172
172
|
return statistic
|
173
173
|
|
174
|
-
def get_all_operations(
|
175
|
-
self, generation_config: GenerationConfig | None = None
|
176
|
-
) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
174
|
+
def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
177
175
|
schema = self.client_schema
|
178
176
|
for root_type, operation_type in (
|
179
177
|
(RootType.QUERY, schema.query_type),
|
@@ -226,7 +224,6 @@ class GraphQLSchema(BaseSchema):
|
|
226
224
|
hooks: HookDispatcher | None = None,
|
227
225
|
auth_storage: AuthStorage | None = None,
|
228
226
|
generation_mode: GenerationMode = GenerationMode.default(),
|
229
|
-
generation_config: GenerationConfig | None = None,
|
230
227
|
**kwargs: Any,
|
231
228
|
) -> SearchStrategy:
|
232
229
|
return graphql_cases(
|
@@ -234,7 +231,6 @@ class GraphQLSchema(BaseSchema):
|
|
234
231
|
hooks=hooks,
|
235
232
|
auth_storage=auth_storage,
|
236
233
|
generation_mode=generation_mode,
|
237
|
-
generation_config=generation_config or self.generation_config,
|
238
234
|
**kwargs,
|
239
235
|
)
|
240
236
|
|
@@ -326,14 +322,13 @@ def graphql_cases(
|
|
326
322
|
hooks: HookDispatcher | None = None,
|
327
323
|
auth_storage: auths.AuthStorage | None = None,
|
328
324
|
generation_mode: GenerationMode = GenerationMode.default(),
|
329
|
-
generation_config: GenerationConfig,
|
330
325
|
path_parameters: NotSet | dict[str, Any] = NOT_SET,
|
331
326
|
headers: NotSet | dict[str, Any] = NOT_SET,
|
332
327
|
cookies: NotSet | dict[str, Any] = NOT_SET,
|
333
328
|
query: NotSet | dict[str, Any] = NOT_SET,
|
334
329
|
body: Any = NOT_SET,
|
335
330
|
media_type: str | None = None,
|
336
|
-
phase: TestPhase = TestPhase.
|
331
|
+
phase: TestPhase = TestPhase.FUZZING,
|
337
332
|
) -> Any:
|
338
333
|
start = time.monotonic()
|
339
334
|
definition = cast(GraphQLOperationDefinition, operation.definition)
|
@@ -343,14 +338,15 @@ def graphql_cases(
|
|
343
338
|
}[definition.root_type]
|
344
339
|
hook_context = HookContext(operation)
|
345
340
|
custom_scalars = {**get_extra_scalar_strategies(), **CUSTOM_SCALARS}
|
341
|
+
generation = operation.schema.config.generation_for(operation=operation, phase="fuzzing")
|
346
342
|
strategy = strategy_factory(
|
347
343
|
operation.schema.client_schema, # type: ignore[attr-defined]
|
348
344
|
fields=[definition.field_name],
|
349
345
|
custom_scalars=custom_scalars,
|
350
346
|
print_ast=_noop, # type: ignore
|
351
|
-
allow_x00=
|
352
|
-
allow_null=
|
353
|
-
codec=
|
347
|
+
allow_x00=generation.allow_x00,
|
348
|
+
allow_null=generation.graphql_allow_null,
|
349
|
+
codec=generation.codec,
|
354
350
|
)
|
355
351
|
strategy = apply_to_all_dispatchers(operation, hook_context, hooks, strategy, "body").map(graphql.print_ast)
|
356
352
|
body = draw(strategy)
|
@@ -361,8 +357,8 @@ def graphql_cases(
|
|
361
357
|
query_ = _generate_parameter("query", query, draw, operation, hook_context, hooks)
|
362
358
|
|
363
359
|
_phase_data = {
|
364
|
-
TestPhase.
|
365
|
-
TestPhase.
|
360
|
+
TestPhase.EXAMPLES: ExplicitPhaseData(),
|
361
|
+
TestPhase.FUZZING: GeneratePhaseData(),
|
366
362
|
}[phase]
|
367
363
|
phase_data = cast(Union[ExplicitPhaseData, GeneratePhaseData], _phase_data)
|
368
364
|
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
|
@@ -52,14 +53,14 @@ def openapi_cases(
|
|
52
53
|
hooks: HookDispatcher | None = None,
|
53
54
|
auth_storage: auths.AuthStorage | None = None,
|
54
55
|
generation_mode: GenerationMode = GenerationMode.default(),
|
55
|
-
generation_config: GenerationConfig,
|
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,6 +77,9 @@ def openapi_cases(
|
|
76
77
|
start = time.monotonic()
|
77
78
|
strategy_factory = GENERATOR_MODE_TO_STRATEGY_FACTORY[generation_mode]
|
78
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
|
+
|
79
83
|
context = HookContext(operation)
|
80
84
|
|
81
85
|
path_parameters_ = generate_parameter(
|
@@ -147,8 +151,8 @@ def openapi_cases(
|
|
147
151
|
reject()
|
148
152
|
|
149
153
|
_phase_data = {
|
150
|
-
TestPhase.
|
151
|
-
TestPhase.
|
154
|
+
TestPhase.EXAMPLES: ExplicitPhaseData(),
|
155
|
+
TestPhase.FUZZING: GeneratePhaseData(),
|
152
156
|
}[phase]
|
153
157
|
phase_data = cast(Union[ExplicitPhaseData, GeneratePhaseData], _phase_data)
|
154
158
|
|
@@ -407,10 +411,10 @@ def _build_custom_formats(
|
|
407
411
|
custom_formats: dict[str, st.SearchStrategy] | None, generation_config: GenerationConfig
|
408
412
|
) -> dict[str, st.SearchStrategy]:
|
409
413
|
custom_formats = {**get_default_format_strategies(), **STRING_FORMATS, **(custom_formats or {})}
|
410
|
-
if generation_config.
|
411
|
-
custom_formats[HEADER_FORMAT] = generation_config.
|
414
|
+
if generation_config.exclude_header_characters is not None:
|
415
|
+
custom_formats[HEADER_FORMAT] = header_values(exclude_characters=generation_config.exclude_header_characters)
|
412
416
|
elif not generation_config.allow_x00:
|
413
|
-
custom_formats[HEADER_FORMAT] = header_values(
|
417
|
+
custom_formats[HEADER_FORMAT] = header_values(exclude_characters="\n\r\x00")
|
414
418
|
return custom_formats
|
415
419
|
|
416
420
|
|
@@ -22,9 +22,6 @@ from schemathesis.openapi.checks import (
|
|
22
22
|
MalformedMediaType,
|
23
23
|
MissingContentType,
|
24
24
|
MissingHeaders,
|
25
|
-
MissingRequiredHeaderConfig,
|
26
|
-
NegativeDataRejectionConfig,
|
27
|
-
PositiveDataAcceptanceConfig,
|
28
25
|
RejectedPositiveData,
|
29
26
|
UndefinedContentType,
|
30
27
|
UndefinedStatusCode,
|
@@ -185,7 +182,7 @@ def response_headers_conformance(ctx: CheckContext, response: Response, case: Ca
|
|
185
182
|
title="Response header does not conform to the schema",
|
186
183
|
operation=case.operation.label,
|
187
184
|
exc=exc,
|
188
|
-
|
185
|
+
config=case.operation.schema.config.output,
|
189
186
|
)
|
190
187
|
)
|
191
188
|
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
@@ -233,8 +230,8 @@ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -
|
|
233
230
|
):
|
234
231
|
return True
|
235
232
|
|
236
|
-
config = ctx.config.
|
237
|
-
allowed_statuses = expand_status_codes(config.
|
233
|
+
config = ctx.config.negative_data_rejection
|
234
|
+
allowed_statuses = expand_status_codes(config.expected_statuses or [])
|
238
235
|
|
239
236
|
if (
|
240
237
|
case.meta.generation.mode.is_negative
|
@@ -243,9 +240,9 @@ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -
|
|
243
240
|
):
|
244
241
|
raise AcceptedNegativeData(
|
245
242
|
operation=case.operation.label,
|
246
|
-
message=f"Allowed statuses: {', '.join(config.
|
243
|
+
message=f"Allowed statuses: {', '.join(config.expected_statuses)}",
|
247
244
|
status_code=response.status_code,
|
248
|
-
|
245
|
+
expected_statuses=config.expected_statuses,
|
249
246
|
)
|
250
247
|
return None
|
251
248
|
|
@@ -261,21 +258,21 @@ def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case)
|
|
261
258
|
):
|
262
259
|
return True
|
263
260
|
|
264
|
-
config = ctx.config.
|
265
|
-
allowed_statuses = expand_status_codes(config.
|
261
|
+
config = ctx.config.positive_data_acceptance
|
262
|
+
allowed_statuses = expand_status_codes(config.expected_statuses or [])
|
266
263
|
|
267
264
|
if case.meta.generation.mode.is_positive and response.status_code not in allowed_statuses:
|
268
265
|
raise RejectedPositiveData(
|
269
266
|
operation=case.operation.label,
|
270
|
-
message=f"Allowed statuses: {', '.join(config.
|
267
|
+
message=f"Allowed statuses: {', '.join(config.expected_statuses)}",
|
271
268
|
status_code=response.status_code,
|
272
|
-
allowed_statuses=config.
|
269
|
+
allowed_statuses=config.expected_statuses,
|
273
270
|
)
|
274
271
|
return None
|
275
272
|
|
276
273
|
|
274
|
+
@schemathesis.check
|
277
275
|
def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
278
|
-
# NOTE: This check is intentionally not registered with `@schemathesis.check` because it is experimental
|
279
276
|
meta = case.meta
|
280
277
|
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or is_unexpected_http_status_case(case):
|
281
278
|
return None
|
@@ -287,16 +284,17 @@ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -
|
|
287
284
|
and data.description.startswith("Missing ")
|
288
285
|
):
|
289
286
|
if data.parameter.lower() == "authorization":
|
290
|
-
|
287
|
+
expected_statuses = {401}
|
291
288
|
else:
|
292
|
-
config = ctx.config.
|
293
|
-
|
294
|
-
if response.status_code not in
|
295
|
-
allowed = f"Allowed statuses: {', '.join(map(str,
|
289
|
+
config = ctx.config.missing_required_header
|
290
|
+
expected_statuses = expand_status_codes(config.expected_statuses or [])
|
291
|
+
if response.status_code not in expected_statuses:
|
292
|
+
allowed = f"Allowed statuses: {', '.join(map(str, expected_statuses))}"
|
296
293
|
raise AssertionError(f"Unexpected response status for a missing header: {response.status_code}\n{allowed}")
|
297
294
|
return None
|
298
295
|
|
299
296
|
|
297
|
+
@schemathesis.check
|
300
298
|
def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
301
299
|
meta = case.meta
|
302
300
|
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or response.request.method == "OPTIONS":
|
@@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, Generator, Iterator, Union, cast
|
|
9
9
|
import requests
|
10
10
|
from hypothesis_jsonschema import from_schema
|
11
11
|
|
12
|
+
from schemathesis.config import GenerationConfig
|
12
13
|
from schemathesis.core.transforms import deepclone
|
13
14
|
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
|
14
|
-
from schemathesis.generation import GenerationConfig
|
15
15
|
from schemathesis.generation.case import Case
|
16
16
|
from schemathesis.generation.hypothesis import examples
|
17
17
|
from schemathesis.generation.meta import TestPhase
|
@@ -68,7 +68,7 @@ def get_strategies_from_examples(
|
|
68
68
|
# Add examples from parameter's schemas
|
69
69
|
examples.extend(extract_from_schemas(operation))
|
70
70
|
return [
|
71
|
-
openapi_cases(operation=operation, **{**parameters, **kwargs, "phase": TestPhase.
|
71
|
+
openapi_cases(operation=operation, **{**parameters, **kwargs, "phase": TestPhase.EXAMPLES}).map(
|
72
72
|
serialize_components
|
73
73
|
)
|
74
74
|
for parameters in produce_combinations(examples)
|
@@ -274,12 +274,13 @@ def extract_from_schema(
|
|
274
274
|
continue
|
275
275
|
variants[name] = values
|
276
276
|
if variants:
|
277
|
+
config = operation.schema.config.generation_for(operation=operation, phase="examples")
|
277
278
|
for name, subschema in to_generate.items():
|
278
279
|
if name in variants:
|
279
280
|
# Generated by one of `anyOf` or similar sub-schemas
|
280
281
|
continue
|
281
282
|
subschema = operation.schema.prepare_schema(subschema)
|
282
|
-
generated = _generate_single_example(subschema,
|
283
|
+
generated = _generate_single_example(subschema, config)
|
283
284
|
variants[name] = [generated]
|
284
285
|
# Calculate the maximum number of examples any property has
|
285
286
|
total_combos = max(len(examples) for examples in variants.values())
|
@@ -38,11 +38,11 @@ def unregister_string_format(name: str) -> None:
|
|
38
38
|
raise ValueError(f"Unknown Open API format: {name}") from exc
|
39
39
|
|
40
40
|
|
41
|
-
def header_values(
|
41
|
+
def header_values(exclude_characters: str = "\n\r") -> st.SearchStrategy[str]:
|
42
42
|
from hypothesis import strategies as st
|
43
43
|
|
44
44
|
return st.text(
|
45
|
-
alphabet=st.characters(min_codepoint=0, max_codepoint=255,
|
45
|
+
alphabet=st.characters(min_codepoint=0, max_codepoint=255, exclude_characters=exclude_characters)
|
46
46
|
# Header values with leading non-visible chars can't be sent with `requests`
|
47
47
|
).map(str.lstrip)
|
48
48
|
|
@@ -9,12 +9,12 @@ import jsonschema
|
|
9
9
|
from hypothesis import strategies as st
|
10
10
|
from hypothesis_jsonschema import from_schema
|
11
11
|
|
12
|
+
from schemathesis.config import GenerationConfig
|
13
|
+
|
12
14
|
from ..constants import ALL_KEYWORDS
|
13
15
|
from .mutations import MutationContext
|
14
16
|
|
15
17
|
if TYPE_CHECKING:
|
16
|
-
from schemathesis.generation import GenerationConfig
|
17
|
-
|
18
18
|
from .types import Draw, Schema
|
19
19
|
|
20
20
|
|
@@ -19,6 +19,7 @@ if hasattr(sre, "POSSESSIVE_REPEAT"):
|
|
19
19
|
else:
|
20
20
|
REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT)
|
21
21
|
LITERAL = sre.LITERAL
|
22
|
+
NOT_LITERAL = sre.NOT_LITERAL
|
22
23
|
IN = sre.IN
|
23
24
|
MAXREPEAT = sre_parse.MAXREPEAT
|
24
25
|
|
@@ -114,8 +115,20 @@ def _handle_anchored_pattern(parsed: list, pattern: str, min_length: int | None,
|
|
114
115
|
|
115
116
|
pattern_parts = parsed[1:-1]
|
116
117
|
|
118
|
+
# Calculate total fixed length and per-repetition lengths
|
119
|
+
fixed_length = 0
|
120
|
+
quantifier_bounds = []
|
121
|
+
repetition_lengths = []
|
122
|
+
|
123
|
+
for op, value in pattern_parts:
|
124
|
+
if op in (LITERAL, NOT_LITERAL):
|
125
|
+
fixed_length += 1
|
126
|
+
elif op in REPEATS:
|
127
|
+
min_repeat, max_repeat, subpattern = value
|
128
|
+
quantifier_bounds.append((min_repeat, max_repeat))
|
129
|
+
repetition_lengths.append(_calculate_min_repetition_length(subpattern))
|
130
|
+
|
117
131
|
# Adjust length constraints by subtracting fixed literals length
|
118
|
-
fixed_length = sum(1 for op, _ in pattern_parts if op == LITERAL)
|
119
132
|
if min_length is not None:
|
120
133
|
min_length -= fixed_length
|
121
134
|
if min_length < 0:
|
@@ -125,13 +138,10 @@ def _handle_anchored_pattern(parsed: list, pattern: str, min_length: int | None,
|
|
125
138
|
if max_length < 0:
|
126
139
|
return pattern
|
127
140
|
|
128
|
-
# Extract only min/max bounds from quantified parts
|
129
|
-
quantifier_bounds = [value[:2] for op, value in pattern_parts if op in REPEATS]
|
130
|
-
|
131
141
|
if not quantifier_bounds:
|
132
142
|
return pattern
|
133
143
|
|
134
|
-
length_distribution = _distribute_length_constraints(quantifier_bounds, min_length, max_length)
|
144
|
+
length_distribution = _distribute_length_constraints(quantifier_bounds, repetition_lengths, min_length, max_length)
|
135
145
|
if not length_distribution:
|
136
146
|
return pattern
|
137
147
|
|
@@ -212,7 +222,7 @@ def _find_quantified_end(pattern: str, start: int) -> int:
|
|
212
222
|
|
213
223
|
|
214
224
|
def _distribute_length_constraints(
|
215
|
-
bounds: list[tuple[int, int]], min_length: int | None, max_length: int | None
|
225
|
+
bounds: list[tuple[int, int]], repetition_lengths: list[int], min_length: int | None, max_length: int | None
|
216
226
|
) -> list[tuple[int, int]] | None:
|
217
227
|
"""Distribute length constraints among quantified pattern parts."""
|
218
228
|
# Handle exact length case with dynamic programming
|
@@ -228,18 +238,22 @@ def _distribute_length_constraints(
|
|
228
238
|
if pos == len(bounds):
|
229
239
|
return [()] if remaining == 0 else None
|
230
240
|
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
241
|
+
max_repeat: int
|
242
|
+
min_repeat, max_repeat = bounds[pos]
|
243
|
+
repeat_length = repetition_lengths[pos]
|
244
|
+
|
245
|
+
if max_repeat == MAXREPEAT:
|
246
|
+
max_repeat = remaining // repeat_length + 1 if repeat_length > 0 else remaining + 1
|
237
247
|
|
238
248
|
# Try each possible length for current quantifier
|
239
|
-
for
|
240
|
-
|
249
|
+
for repeat_count in range(min_repeat, max_repeat + 1):
|
250
|
+
used_length = repeat_count * repeat_length
|
251
|
+
if used_length > remaining:
|
252
|
+
break
|
253
|
+
|
254
|
+
rest = find_valid_combination(pos + 1, remaining - used_length)
|
241
255
|
if rest is not None:
|
242
|
-
dp[(pos, remaining)] = [(
|
256
|
+
dp[(pos, remaining)] = [(repeat_count,) + r for r in rest]
|
243
257
|
return dp[(pos, remaining)]
|
244
258
|
|
245
259
|
dp[(pos, remaining)] = None
|
@@ -280,6 +294,22 @@ def _distribute_length_constraints(
|
|
280
294
|
return result
|
281
295
|
|
282
296
|
|
297
|
+
def _calculate_min_repetition_length(subpattern: list) -> int:
|
298
|
+
"""Calculate minimum length contribution per repetition of a quantified group."""
|
299
|
+
total = 0
|
300
|
+
for op, value in subpattern:
|
301
|
+
if op in [LITERAL, NOT_LITERAL, IN, sre.ANY]:
|
302
|
+
total += 1
|
303
|
+
elif op == sre.SUBPATTERN:
|
304
|
+
_, _, _, inner_pattern = value
|
305
|
+
total += _calculate_min_repetition_length(inner_pattern)
|
306
|
+
elif op in REPEATS:
|
307
|
+
min_repeat, _, inner_pattern = value
|
308
|
+
inner_min = _calculate_min_repetition_length(inner_pattern)
|
309
|
+
total += min_repeat * inner_min
|
310
|
+
return total
|
311
|
+
|
312
|
+
|
283
313
|
def _get_anchor_length(node_type: int) -> int:
|
284
314
|
"""Determine the length of the anchor based on its type."""
|
285
315
|
if node_type in {sre.AT_BEGINNING_STRING, sre.AT_END_STRING, sre.AT_BOUNDARY, sre.AT_NON_BOUNDARY}:
|
@@ -293,7 +323,7 @@ def _update_quantifier(
|
|
293
323
|
"""Update the quantifier based on the operation type and given constraints."""
|
294
324
|
if op in REPEATS and value is not None:
|
295
325
|
return _handle_repeat_quantifier(value, pattern, min_length, max_length)
|
296
|
-
if op in (LITERAL, IN) and max_length != 0:
|
326
|
+
if op in (LITERAL, NOT_LITERAL, IN) and max_length != 0:
|
297
327
|
return _handle_literal_or_in_quantifier(pattern, min_length, max_length)
|
298
328
|
if op == sre.ANY and value is None:
|
299
329
|
# Equivalent to `.` which is in turn is the same as `.{1}`
|
@@ -5,10 +5,9 @@ from functools import lru_cache
|
|
5
5
|
from typing import Any, Callable, Dict, Union, overload
|
6
6
|
from urllib.request import urlopen
|
7
7
|
|
8
|
-
import jsonschema
|
9
8
|
import requests
|
10
9
|
|
11
|
-
from schemathesis.core.compat import RefResolutionError
|
10
|
+
from schemathesis.core.compat import RefResolutionError, RefResolver
|
12
11
|
from schemathesis.core.deserialization import deserialize_yaml
|
13
12
|
from schemathesis.core.transforms import deepclone
|
14
13
|
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
|
@@ -48,7 +47,7 @@ def load_remote_uri(uri: str) -> Any:
|
|
48
47
|
JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
|
49
48
|
|
50
49
|
|
51
|
-
class InliningResolver(
|
50
|
+
class InliningResolver(RefResolver):
|
52
51
|
"""Inlines resolved schemas."""
|
53
52
|
|
54
53
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|