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.
Files changed (111) hide show
  1. schemathesis/__init__.py +29 -30
  2. schemathesis/auths.py +65 -24
  3. schemathesis/checks.py +73 -39
  4. schemathesis/cli/commands/__init__.py +51 -3
  5. schemathesis/cli/commands/data.py +10 -0
  6. schemathesis/cli/commands/run/__init__.py +163 -274
  7. schemathesis/cli/commands/run/context.py +8 -4
  8. schemathesis/cli/commands/run/events.py +11 -1
  9. schemathesis/cli/commands/run/executor.py +70 -78
  10. schemathesis/cli/commands/run/filters.py +15 -165
  11. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  12. schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
  13. schemathesis/cli/commands/run/handlers/output.py +195 -121
  14. schemathesis/cli/commands/run/loaders.py +35 -50
  15. schemathesis/cli/commands/run/validation.py +52 -162
  16. schemathesis/cli/core.py +5 -3
  17. schemathesis/cli/ext/fs.py +7 -5
  18. schemathesis/cli/ext/options.py +0 -21
  19. schemathesis/config/__init__.py +189 -0
  20. schemathesis/config/_auth.py +51 -0
  21. schemathesis/config/_checks.py +268 -0
  22. schemathesis/config/_diff_base.py +99 -0
  23. schemathesis/config/_env.py +21 -0
  24. schemathesis/config/_error.py +156 -0
  25. schemathesis/config/_generation.py +149 -0
  26. schemathesis/config/_health_check.py +24 -0
  27. schemathesis/config/_operations.py +327 -0
  28. schemathesis/config/_output.py +171 -0
  29. schemathesis/config/_parameters.py +19 -0
  30. schemathesis/config/_phases.py +187 -0
  31. schemathesis/config/_projects.py +523 -0
  32. schemathesis/config/_rate_limit.py +17 -0
  33. schemathesis/config/_report.py +120 -0
  34. schemathesis/config/_validator.py +9 -0
  35. schemathesis/config/_warnings.py +25 -0
  36. schemathesis/config/schema.json +885 -0
  37. schemathesis/core/__init__.py +2 -0
  38. schemathesis/core/compat.py +16 -9
  39. schemathesis/core/errors.py +24 -4
  40. schemathesis/core/failures.py +6 -7
  41. schemathesis/core/hooks.py +20 -0
  42. schemathesis/core/output/__init__.py +14 -37
  43. schemathesis/core/output/sanitization.py +3 -146
  44. schemathesis/core/transport.py +36 -1
  45. schemathesis/core/validation.py +16 -0
  46. schemathesis/engine/__init__.py +2 -4
  47. schemathesis/engine/context.py +42 -43
  48. schemathesis/engine/core.py +7 -5
  49. schemathesis/engine/errors.py +60 -1
  50. schemathesis/engine/events.py +10 -2
  51. schemathesis/engine/phases/__init__.py +10 -0
  52. schemathesis/engine/phases/probes.py +11 -8
  53. schemathesis/engine/phases/stateful/__init__.py +2 -1
  54. schemathesis/engine/phases/stateful/_executor.py +104 -46
  55. schemathesis/engine/phases/stateful/context.py +2 -2
  56. schemathesis/engine/phases/unit/__init__.py +23 -15
  57. schemathesis/engine/phases/unit/_executor.py +110 -21
  58. schemathesis/engine/phases/unit/_pool.py +1 -1
  59. schemathesis/errors.py +2 -0
  60. schemathesis/filters.py +2 -3
  61. schemathesis/generation/__init__.py +5 -33
  62. schemathesis/generation/case.py +6 -3
  63. schemathesis/generation/coverage.py +154 -124
  64. schemathesis/generation/hypothesis/builder.py +70 -20
  65. schemathesis/generation/meta.py +3 -3
  66. schemathesis/generation/metrics.py +93 -0
  67. schemathesis/generation/modes.py +0 -8
  68. schemathesis/generation/overrides.py +37 -1
  69. schemathesis/generation/stateful/__init__.py +4 -0
  70. schemathesis/generation/stateful/state_machine.py +9 -1
  71. schemathesis/graphql/loaders.py +159 -16
  72. schemathesis/hooks.py +62 -35
  73. schemathesis/openapi/checks.py +12 -8
  74. schemathesis/openapi/generation/filters.py +10 -8
  75. schemathesis/openapi/loaders.py +142 -17
  76. schemathesis/pytest/lazy.py +2 -5
  77. schemathesis/pytest/loaders.py +24 -0
  78. schemathesis/pytest/plugin.py +33 -2
  79. schemathesis/schemas.py +21 -66
  80. schemathesis/specs/graphql/scalars.py +37 -3
  81. schemathesis/specs/graphql/schemas.py +23 -18
  82. schemathesis/specs/openapi/_hypothesis.py +26 -28
  83. schemathesis/specs/openapi/checks.py +37 -36
  84. schemathesis/specs/openapi/examples.py +4 -3
  85. schemathesis/specs/openapi/formats.py +32 -5
  86. schemathesis/specs/openapi/media_types.py +44 -1
  87. schemathesis/specs/openapi/negative/__init__.py +2 -2
  88. schemathesis/specs/openapi/patterns.py +46 -16
  89. schemathesis/specs/openapi/references.py +2 -3
  90. schemathesis/specs/openapi/schemas.py +19 -22
  91. schemathesis/specs/openapi/stateful/__init__.py +12 -6
  92. schemathesis/transport/__init__.py +54 -16
  93. schemathesis/transport/prepare.py +38 -13
  94. schemathesis/transport/requests.py +12 -9
  95. schemathesis/transport/wsgi.py +11 -12
  96. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +50 -97
  97. schemathesis-4.0.0a12.dist-info/RECORD +164 -0
  98. schemathesis/cli/commands/run/checks.py +0 -79
  99. schemathesis/cli/commands/run/hypothesis.py +0 -78
  100. schemathesis/cli/commands/run/reports.py +0 -72
  101. schemathesis/cli/hooks.py +0 -36
  102. schemathesis/contrib/__init__.py +0 -9
  103. schemathesis/contrib/openapi/__init__.py +0 -9
  104. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  105. schemathesis/engine/config.py +0 -59
  106. schemathesis/experimental/__init__.py +0 -72
  107. schemathesis/generation/targets.py +0 -69
  108. schemathesis-4.0.0a10.dist-info/RECORD +0 -153
  109. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/WHEEL +0 -0
  110. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
  111. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/licenses/LICENSE +0 -0
@@ -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(generation_config=generation_config):
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
- generation=generation_config,
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
  )
@@ -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)
@@ -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=list(HypothesisTestMode),
152
+ modes=modes,
142
153
  given_kwargs=self.given_kwargs,
143
- generation=self.schema.generation_config,
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, urlparse, urlsplit, urlunsplit
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 GenerationConfig, GenerationMode
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.register(hook)
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.default(),
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.default(),
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
- 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)
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.default(),
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.default(),
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 new strategy for generating custom scalars.
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 GenerationConfig, GenerationMode
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.default(),
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.default(),
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.GENERATE,
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=generation_config.allow_x00,
352
- allow_null=generation_config.graphql_allow_null,
353
- codec=generation_config.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.EXPLICIT: ExplicitPhaseData(),
365
- TestPhase.GENERATE: GeneratePhaseData(),
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 GenerationConfig, GenerationMode
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.default(),
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.GENERATE,
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
- context = HookContext(operation)
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, context, hooks, generation_mode, generation_config
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
- cookies_ = generate_parameter(
88
- "cookie", cookies, operation, draw, context, hooks, generation_mode, generation_config
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, context, hooks, strategy, "body")
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.EXPLICIT: ExplicitPhaseData(),
151
- TestPhase.GENERATE: GeneratePhaseData(),
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
- context: HookContext,
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, context, hooks, strategy, location)
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, context, hooks, strategy, location)
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
- context: HookContext,
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.headers.strategy is not None:
411
- custom_formats[HEADER_FORMAT] = generation_config.headers.strategy
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(blacklist_characters="\n\r\x00")
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
- context: HookContext,
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, context, hooks, strategy, container)
498
+ return apply_to_all_dispatchers(operation, ctx, hooks, strategy, container)