schemathesis 3.34.3__py3-none-any.whl → 3.35.1__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.
@@ -3,26 +3,28 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import warnings
7
8
  from typing import Any, Callable, Generator, Mapping, Optional, Tuple
8
9
 
9
10
  import hypothesis
10
11
  from hypothesis import Phase
11
- from hypothesis import strategies as st
12
12
  from hypothesis.errors import HypothesisWarning, Unsatisfiable
13
13
  from hypothesis.internal.entropy import deterministic_PRNG
14
14
  from hypothesis.internal.reflection import proxies
15
15
  from jsonschema.exceptions import SchemaError
16
16
 
17
17
  from .auths import get_auth_storage_from_test
18
- from .constants import DEFAULT_DEADLINE
18
+ from .constants import DEFAULT_DEADLINE, NOT_SET
19
19
  from .exceptions import OperationSchemaError, SerializationNotPossible
20
- from .generation import DataGenerationMethod, GenerationConfig
20
+ from .experimental import COVERAGE_PHASE
21
+ from .generation import DataGenerationMethod, GenerationConfig, combine_strategies, coverage, get_single_example
21
22
  from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher
22
- from .models import APIOperation, Case
23
+ from .models import APIOperation, Case, GenerationMetadata, TestPhase
23
24
  from .transports.content_types import parse_content_type
24
25
  from .transports.headers import has_invalid_characters, is_latin_1_encodable
25
- from .utils import GivenInput, combine_strategies
26
+ from .types import NotSet
27
+ from .utils import GivenInput
26
28
 
27
29
  # Forcefully initializes Hypothesis' global PRNG to avoid races that initilize it
28
30
  # if e.g. Schemathesis CLI is used with multiple workers
@@ -101,6 +103,8 @@ def create_test(
101
103
  wrapped_test = add_examples(
102
104
  wrapped_test, operation, hook_dispatcher=hook_dispatcher, as_strategy_kwargs=as_strategy_kwargs
103
105
  )
106
+ if COVERAGE_PHASE.is_enabled:
107
+ wrapped_test = add_coverage(wrapped_test, operation, data_generation_methods)
104
108
  return wrapped_test
105
109
 
106
110
 
@@ -199,6 +203,79 @@ def add_examples(
199
203
  return test
200
204
 
201
205
 
206
+ def add_coverage(
207
+ test: Callable, operation: APIOperation, data_generation_methods: list[DataGenerationMethod]
208
+ ) -> Callable:
209
+ for example in _iter_coverage_cases(operation, data_generation_methods):
210
+ test = hypothesis.example(case=example)(test)
211
+ return test
212
+
213
+
214
+ def _iter_coverage_cases(
215
+ operation: APIOperation, data_generation_methods: list[DataGenerationMethod]
216
+ ) -> Generator[Case, None, None]:
217
+ from .specs.openapi.constants import LOCATION_TO_CONTAINER
218
+
219
+ ctx = coverage.CoverageContext(data_generation_methods=data_generation_methods)
220
+ meta = GenerationMetadata(
221
+ query=None, path_parameters=None, headers=None, cookies=None, body=None, phase=TestPhase.COVERAGE
222
+ )
223
+ generators: dict[tuple[str, str], Generator[coverage.GeneratedValue, None, None]] = {}
224
+ template: dict[str, Any] = {}
225
+ template_generation_method = DataGenerationMethod.positive
226
+ for parameter in operation.iter_parameters():
227
+ schema = parameter.as_json_schema(operation)
228
+ gen = coverage.cover_schema_iter(ctx, schema)
229
+ value = next(gen, NOT_SET)
230
+ if isinstance(value, NotSet):
231
+ continue
232
+ location = parameter.location
233
+ name = parameter.name
234
+ container = template.setdefault(LOCATION_TO_CONTAINER[location], {})
235
+ if location in ("header", "cookie") and not isinstance(value.value, str):
236
+ container[name] = json.dumps(value.value)
237
+ else:
238
+ container[name] = value.value
239
+ template_generation_method = value.data_generation_method
240
+ generators[(location, name)] = gen
241
+ if operation.body:
242
+ for body in operation.body:
243
+ schema = body.as_json_schema(operation)
244
+ gen = coverage.cover_schema_iter(ctx, schema)
245
+ value = next(gen, NOT_SET)
246
+ if isinstance(value, NotSet):
247
+ continue
248
+ if "body" not in template:
249
+ template["body"] = value.value
250
+ template["media_type"] = body.media_type
251
+ case = operation.make_case(**{**template, "body": value.value, "media_type": body.media_type})
252
+ case.data_generation_method = value.data_generation_method
253
+ case.meta = meta
254
+ yield case
255
+ for next_value in gen:
256
+ case = operation.make_case(**{**template, "body": next_value.value, "media_type": body.media_type})
257
+ case.data_generation_method = next_value.data_generation_method
258
+ case.meta = meta
259
+ yield case
260
+ else:
261
+ case = operation.make_case(**template)
262
+ case.data_generation_method = template_generation_method
263
+ case.meta = meta
264
+ yield case
265
+ for (location, name), gen in generators.items():
266
+ container_name = LOCATION_TO_CONTAINER[location]
267
+ container = template[container_name]
268
+ for value in gen:
269
+ if location in ("header", "cookie") and not isinstance(value.value, str):
270
+ generated = json.dumps(value.value)
271
+ else:
272
+ generated = value.value
273
+ case = operation.make_case(**{**template, container_name: {**container, name: generated}})
274
+ case.data_generation_method = value.data_generation_method
275
+ case.meta = meta
276
+ yield case
277
+
278
+
202
279
  def find_invalid_headers(headers: Mapping) -> Generator[Tuple[str, str], None, None]:
203
280
  for name, value in headers.items():
204
281
  if not is_latin_1_encodable(value) or has_invalid_characters(name, value):
@@ -248,25 +325,3 @@ def get_invalid_example_headers_mark(test: Callable) -> Optional[dict[str, str]]
248
325
 
249
326
  def add_invalid_example_header_mark(test: Callable, headers: dict[str, str]) -> None:
250
327
  test._schemathesis_invalid_example_headers = headers # type: ignore
251
-
252
-
253
- def get_single_example(strategy: st.SearchStrategy[Case]) -> Case:
254
- examples: list[Case] = []
255
- add_single_example(strategy, examples)
256
- return examples[0]
257
-
258
-
259
- def add_single_example(strategy: st.SearchStrategy[Case], examples: list[Case]) -> None:
260
- @hypothesis.given(strategy) # type: ignore
261
- @hypothesis.settings( # type: ignore
262
- database=None,
263
- max_examples=1,
264
- deadline=None,
265
- verbosity=hypothesis.Verbosity.quiet,
266
- phases=(hypothesis.Phase.generate,),
267
- suppress_health_check=list(hypothesis.HealthCheck),
268
- )
269
- def example_generating_inner_function(ex: Case) -> None:
270
- examples.append(ex)
271
-
272
- example_generating_inner_function()
@@ -11,7 +11,7 @@ from collections import defaultdict
11
11
  from dataclasses import dataclass
12
12
  from enum import Enum
13
13
  from queue import Queue
14
- from typing import TYPE_CHECKING, Any, Callable, Generator, Iterable, Literal, NoReturn, Sequence, cast
14
+ from typing import TYPE_CHECKING, Any, Callable, Generator, Iterable, Literal, NoReturn, Sequence, Type, cast
15
15
  from urllib.parse import urlparse
16
16
 
17
17
  import click
@@ -42,7 +42,7 @@ from ..internal.datetime import current_datetime
42
42
  from ..internal.output import OutputConfig
43
43
  from ..internal.validation import file_exists
44
44
  from ..loaders import load_app, load_yaml
45
- from ..models import APIOperation, Case, CheckFunction
45
+ from ..models import Case, CheckFunction
46
46
  from ..runner import events, prepare_hypothesis_settings, probes
47
47
  from ..specs.graphql import loaders as gql_loaders
48
48
  from ..specs.openapi import loaders as oas_loaders
@@ -50,11 +50,12 @@ from ..stateful import Stateful
50
50
  from ..targets import Target
51
51
  from ..transports import RequestConfig
52
52
  from ..transports.auth import get_requests_auth
53
- from ..types import Filter, PathLike, RequestCert
53
+ from ..types import PathLike, RequestCert
54
54
  from . import callbacks, cassettes, output
55
55
  from .constants import DEFAULT_WORKERS, MAX_WORKERS, MIN_WORKERS, HealthCheck, Phase, Verbosity
56
56
  from .context import ExecutionContext, FileReportContext, ServiceReportContext
57
57
  from .debug import DebugOutputHandler
58
+ from .handlers import EventHandler
58
59
  from .junitxml import JunitXMLHandler
59
60
  from .options import CsvChoice, CsvEnumChoice, CustomHelpMessageChoice, NotSet, OptionalInt
60
61
  from .sanitization import SanitizationHandler
@@ -66,13 +67,13 @@ if TYPE_CHECKING:
66
67
  from ..schemas import BaseSchema
67
68
  from ..service.client import ServiceClient
68
69
  from ..specs.graphql.schemas import GraphQLSchema
69
- from .handlers import EventHandler
70
70
 
71
71
 
72
72
  def _get_callable_names(items: tuple[Callable, ...]) -> tuple[str, ...]:
73
73
  return tuple(item.__name__ for item in items)
74
74
 
75
75
 
76
+ CUSTOM_HANDLERS: list[Type[EventHandler]] = []
76
77
  CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
77
78
 
78
79
  DEFAULT_CHECKS_NAMES = _get_callable_names(checks_module.DEFAULT_CHECKS)
@@ -759,6 +760,7 @@ The report data, consisting of a tar gz file with multiple JSON files, is subjec
759
760
  experimental.SCHEMA_ANALYSIS.name,
760
761
  experimental.STATEFUL_TEST_RUNNER.name,
761
762
  experimental.STATEFUL_ONLY.name,
763
+ experimental.COVERAGE_PHASE.name,
762
764
  ]
763
765
  ),
764
766
  callback=callbacks.convert_experimental,
@@ -927,6 +929,7 @@ def run(
927
929
  schemathesis_io_telemetry: bool = True,
928
930
  hosts_file: PathLike = service.DEFAULT_HOSTS_PATH,
929
931
  force_color: bool = False,
932
+ **__kwargs,
930
933
  ) -> None:
931
934
  """Run tests against an API using a specified SCHEMA.
932
935
 
@@ -1212,6 +1215,7 @@ def run(
1212
1215
  )
1213
1216
  execute(
1214
1217
  event_stream,
1218
+ ctx=ctx,
1215
1219
  hypothesis_settings=hypothesis_settings,
1216
1220
  workers_num=workers_num,
1217
1221
  rate_limit=rate_limit,
@@ -1595,6 +1599,7 @@ class OutputStyle(Enum):
1595
1599
  def execute(
1596
1600
  event_stream: Generator[events.ExecutionEvent, None, None],
1597
1601
  *,
1602
+ ctx: click.Context,
1598
1603
  hypothesis_settings: hypothesis.settings,
1599
1604
  workers_num: int,
1600
1605
  rate_limit: str | None,
@@ -1669,6 +1674,8 @@ def execute(
1669
1674
  cassette_path, format=cassette_format, preserve_exact_body_bytes=cassette_preserve_exact_body_bytes
1670
1675
  )
1671
1676
  )
1677
+ for custom_handler in CUSTOM_HANDLERS:
1678
+ handlers.append(custom_handler(*ctx.args, **ctx.params))
1672
1679
  handlers.append(get_output_handler(workers_num))
1673
1680
  if sanitize_output:
1674
1681
  handlers.insert(0, SanitizationHandler())
@@ -2000,6 +2007,20 @@ def decide_color_output(ctx: click.Context, no_color: bool, force_color: bool) -
2000
2007
  ctx.color = False
2001
2008
 
2002
2009
 
2010
+ def add_option(*args: Any, cls: Type = click.Option, **kwargs: Any) -> None:
2011
+ """Add a new CLI option to `st run`."""
2012
+ run.params.append(cls(args, **kwargs))
2013
+
2014
+
2015
+ def handler() -> Callable[[Type], None]:
2016
+ """Register a new CLI event handler."""
2017
+
2018
+ def _wrapper(cls: Type) -> None:
2019
+ CUSTOM_HANDLERS.append(cls)
2020
+
2021
+ return _wrapper
2022
+
2023
+
2003
2024
  @HookDispatcher.register_spec([HookScope.GLOBAL])
2004
2025
  def after_init_cli_run_handlers(
2005
2026
  context: HookContext, handlers: list[EventHandler], execution_context: ExecutionContext
@@ -226,6 +226,7 @@ http_interactions:"""
226
226
  for interaction in item.interactions:
227
227
  status = interaction.status.name.upper()
228
228
  # Body payloads are handled via separate `stream.write` calls to avoid some allocations
229
+ phase = f"'{interaction.phase.value}'" if interaction.phase is not None else "null"
229
230
  stream.write(
230
231
  f"""\n- id: '{current_id}'
231
232
  status: '{status}'
@@ -233,6 +234,7 @@ http_interactions:"""
233
234
  thread_id: {item.thread_id}
234
235
  correlation_id: '{item.correlation_id}'
235
236
  data_generation_method: '{interaction.data_generation_method.value}'
237
+ phase: {phase}
236
238
  elapsed: '{interaction.response.elapsed}'
237
239
  recorded_at: '{interaction.recorded_at}'
238
240
  checks:
@@ -60,7 +60,11 @@ class ExecutionContext:
60
60
  analysis: Result[AnalysisResult, Exception] | None = None
61
61
  output_config: OutputConfig = field(default_factory=OutputConfig)
62
62
  state_machine_sink: StateMachineSink | None = None
63
+ summary_lines: list[str] = field(default_factory=list)
63
64
 
64
65
  @deprecated_property(removed_in="4.0", replacement="show_trace")
65
66
  def show_errors_tracebacks(self) -> bool:
66
67
  return self.show_trace
68
+
69
+ def add_summary_line(self, line: str) -> None:
70
+ self.summary_lines.append(line)
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
3
+ from typing import TYPE_CHECKING, Any
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from ..runner import events
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
8
8
 
9
9
 
10
10
  class EventHandler:
11
+ def __init__(self, *args: Any, **params: Any) -> None:
12
+ pass
13
+
11
14
  def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
12
15
  raise NotImplementedError
13
16
 
@@ -850,6 +850,10 @@ def handle_finished(context: ExecutionContext, event: events.Finished) -> None:
850
850
  display_application_logs(context, event)
851
851
  display_analysis(context)
852
852
  display_statistic(context, event)
853
+ if context.summary_lines:
854
+ click.echo()
855
+ for line in context.summary_lines:
856
+ click.echo(line)
853
857
  click.echo()
854
858
  display_summary(event)
855
859
 
@@ -18,7 +18,7 @@ def uninstall() -> None:
18
18
 
19
19
  def before_add_examples(context: HookContext, examples: list[Case]) -> None:
20
20
  if not examples and context.operation is not None:
21
- from ..._hypothesis import add_single_example
21
+ from ...generation import add_single_example
22
22
 
23
23
  strategy = context.operation.as_strategy()
24
24
  add_single_example(strategy, examples)
@@ -93,3 +93,10 @@ STATEFUL_ONLY = GLOBAL_EXPERIMENTS.create_experiment(
93
93
  description="Run only stateful tests",
94
94
  discussion_url="https://github.com/schemathesis/schemathesis/discussions/2262",
95
95
  )
96
+ COVERAGE_PHASE = GLOBAL_EXPERIMENTS.create_experiment(
97
+ name="coverage-phase",
98
+ verbose_name="Coverage phase",
99
+ env_var="COVERAGE_PHASE",
100
+ description="Generate covering test cases",
101
+ discussion_url="https://github.com/schemathesis/schemathesis/discussions/2418",
102
+ )
@@ -2,48 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import random
4
4
  from dataclasses import dataclass, field
5
- from enum import Enum
6
- from typing import TYPE_CHECKING, Iterable, Union
5
+ from typing import TYPE_CHECKING
6
+
7
+ from ._hypothesis import add_single_example, combine_strategies, get_single_example # noqa: E402
8
+ from ._methods import DataGenerationMethod, DataGenerationMethodInput # noqa: E402
7
9
 
8
10
  if TYPE_CHECKING:
9
11
  from hypothesis.strategies import SearchStrategy
10
12
 
11
13
 
12
- class DataGenerationMethod(str, Enum):
13
- """Defines what data Schemathesis generates for tests."""
14
-
15
- # Generate data, that fits the API schema
16
- positive = "positive"
17
- # Doesn't fit the API schema
18
- negative = "negative"
19
-
20
- @classmethod
21
- def default(cls) -> DataGenerationMethod:
22
- return cls.positive
23
-
24
- @classmethod
25
- def all(cls) -> list[DataGenerationMethod]:
26
- return list(DataGenerationMethod)
27
-
28
- def as_short_name(self) -> str:
29
- return {
30
- DataGenerationMethod.positive: "P",
31
- DataGenerationMethod.negative: "N",
32
- }[self]
33
-
34
- @property
35
- def is_negative(self) -> bool:
36
- return self == DataGenerationMethod.negative
37
-
38
- @classmethod
39
- def ensure_list(cls, value: DataGenerationMethodInput) -> list[DataGenerationMethod]:
40
- if isinstance(value, DataGenerationMethod):
41
- return [value]
42
- return list(value)
43
-
44
-
45
- DataGenerationMethodInput = Union[DataGenerationMethod, Iterable[DataGenerationMethod]]
46
-
47
14
  DEFAULT_DATA_GENERATION_METHODS = (DataGenerationMethod.default(),)
48
15
 
49
16
 
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache, reduce
4
+ from operator import or_
5
+ from typing import TYPE_CHECKING, TypeVar
6
+
7
+ if TYPE_CHECKING:
8
+ from hypothesis import settings
9
+ from hypothesis import strategies as st
10
+
11
+
12
+ @lru_cache()
13
+ def default_settings() -> settings:
14
+ from hypothesis import HealthCheck, Phase, Verbosity, settings
15
+
16
+ return settings(
17
+ database=None,
18
+ max_examples=1,
19
+ deadline=None,
20
+ verbosity=Verbosity.quiet,
21
+ phases=(Phase.generate,),
22
+ suppress_health_check=list(HealthCheck),
23
+ )
24
+
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ def get_single_example(strategy: st.SearchStrategy[T]) -> T: # type: ignore[type-var]
30
+ examples: list[T] = []
31
+ add_single_example(strategy, examples)
32
+ return examples[0]
33
+
34
+
35
+ def add_single_example(strategy: st.SearchStrategy[T], examples: list[T]) -> None:
36
+ from hypothesis import given
37
+
38
+ @given(strategy) # type: ignore
39
+ @default_settings() # type: ignore
40
+ def example_generating_inner_function(ex: T) -> None:
41
+ examples.append(ex)
42
+
43
+ example_generating_inner_function()
44
+
45
+
46
+ def combine_strategies(strategies: list[st.SearchStrategy] | tuple[st.SearchStrategy]) -> st.SearchStrategy:
47
+ """Combine a list of strategies into a single one.
48
+
49
+ If the input is `[a, b, c]`, then the result is equivalent to `a | b | c`.
50
+ """
51
+ return reduce(or_, strategies[1:], strategies[0])
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Iterable, Union
5
+
6
+
7
+ class DataGenerationMethod(str, Enum):
8
+ """Defines what data Schemathesis generates for tests."""
9
+
10
+ # Generate data, that fits the API schema
11
+ positive = "positive"
12
+ # Doesn't fit the API schema
13
+ negative = "negative"
14
+
15
+ @classmethod
16
+ def default(cls) -> DataGenerationMethod:
17
+ return cls.positive
18
+
19
+ @classmethod
20
+ def all(cls) -> list[DataGenerationMethod]:
21
+ return list(DataGenerationMethod)
22
+
23
+ def as_short_name(self) -> str:
24
+ return {
25
+ DataGenerationMethod.positive: "P",
26
+ DataGenerationMethod.negative: "N",
27
+ }[self]
28
+
29
+ @property
30
+ def is_negative(self) -> bool:
31
+ return self == DataGenerationMethod.negative
32
+
33
+ @classmethod
34
+ def ensure_list(cls, value: DataGenerationMethodInput) -> list[DataGenerationMethod]:
35
+ if isinstance(value, DataGenerationMethod):
36
+ return [value]
37
+ return list(value)
38
+
39
+
40
+ DataGenerationMethodInput = Union[DataGenerationMethod, Iterable[DataGenerationMethod]]