schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a3__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 (44) hide show
  1. schemathesis/checks.py +6 -4
  2. schemathesis/cli/__init__.py +12 -1
  3. schemathesis/cli/commands/run/__init__.py +4 -4
  4. schemathesis/cli/commands/run/events.py +19 -4
  5. schemathesis/cli/commands/run/executor.py +9 -3
  6. schemathesis/cli/commands/run/filters.py +27 -19
  7. schemathesis/cli/commands/run/handlers/base.py +1 -1
  8. schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
  9. schemathesis/cli/commands/run/handlers/output.py +860 -201
  10. schemathesis/cli/commands/run/validation.py +1 -1
  11. schemathesis/cli/ext/options.py +4 -1
  12. schemathesis/core/errors.py +8 -0
  13. schemathesis/core/failures.py +54 -24
  14. schemathesis/engine/core.py +1 -1
  15. schemathesis/engine/errors.py +11 -5
  16. schemathesis/engine/events.py +3 -97
  17. schemathesis/engine/phases/stateful/__init__.py +2 -0
  18. schemathesis/engine/phases/stateful/_executor.py +22 -50
  19. schemathesis/engine/phases/unit/__init__.py +1 -0
  20. schemathesis/engine/phases/unit/_executor.py +2 -1
  21. schemathesis/engine/phases/unit/_pool.py +1 -1
  22. schemathesis/engine/recorder.py +29 -23
  23. schemathesis/errors.py +19 -13
  24. schemathesis/generation/coverage.py +4 -4
  25. schemathesis/generation/hypothesis/builder.py +15 -12
  26. schemathesis/generation/stateful/state_machine.py +61 -45
  27. schemathesis/graphql/checks.py +3 -9
  28. schemathesis/openapi/checks.py +8 -33
  29. schemathesis/schemas.py +34 -14
  30. schemathesis/specs/graphql/schemas.py +16 -15
  31. schemathesis/specs/openapi/checks.py +50 -27
  32. schemathesis/specs/openapi/expressions/__init__.py +11 -15
  33. schemathesis/specs/openapi/expressions/nodes.py +20 -20
  34. schemathesis/specs/openapi/links.py +139 -118
  35. schemathesis/specs/openapi/patterns.py +170 -2
  36. schemathesis/specs/openapi/schemas.py +60 -36
  37. schemathesis/specs/openapi/stateful/__init__.py +185 -113
  38. schemathesis/specs/openapi/stateful/control.py +87 -0
  39. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
  40. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
  41. schemathesis/specs/openapi/expressions/context.py +0 -14
  42. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
  43. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
  44. {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/licenses/LICENSE +0 -0
schemathesis/checks.py CHANGED
@@ -4,6 +4,7 @@ import json
4
4
  from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Optional
5
5
 
6
6
  from schemathesis.core.failures import (
7
+ CustomFailure,
7
8
  Failure,
8
9
  FailureGroup,
9
10
  MalformedJson,
@@ -72,7 +73,7 @@ class CheckContext:
72
73
 
73
74
  def record_case(self, *, parent_id: str, case: Case) -> None:
74
75
  if self.recorder is not None:
75
- self.recorder.record_case(parent_id=parent_id, case=case)
76
+ self.recorder.record_case(parent_id=parent_id, transition=None, case=case)
76
77
 
77
78
  def record_response(self, *, case_id: str, response: Response) -> None:
78
79
  if self.recorder is not None:
@@ -135,10 +136,11 @@ def run_checks(
135
136
  except Failure as failure:
136
137
  on_failure(name, collected, failure.with_traceback(None))
137
138
  except AssertionError as exc:
138
- custom_failure = Failure.from_assertion(
139
- name=name,
139
+ custom_failure = CustomFailure(
140
140
  operation=case.operation.label,
141
- exc=exc,
141
+ title=f"Custom check failed: `{name}`",
142
+ message=str(exc),
143
+ exception=exc,
142
144
  )
143
145
  on_failure(name, collected, custom_failure)
144
146
  except FailureGroup as group:
@@ -1,11 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from schemathesis.cli.commands import Group, run, schemathesis
4
+ from schemathesis.cli.commands.run.context import ExecutionContext
5
+ from schemathesis.cli.commands.run.events import LoadingFinished, LoadingStarted
4
6
  from schemathesis.cli.commands.run.executor import handler
5
7
  from schemathesis.cli.commands.run.handlers import EventHandler
6
8
  from schemathesis.cli.ext.groups import GROUPS
7
9
 
8
- __all__ = ["schemathesis", "run", "EventHandler", "add_group", "handler"]
10
+ __all__ = [
11
+ "schemathesis",
12
+ "run",
13
+ "EventHandler",
14
+ "ExecutionContext",
15
+ "LoadingStarted",
16
+ "LoadingFinished",
17
+ "add_group",
18
+ "handler",
19
+ ]
9
20
 
10
21
 
11
22
  def add_group(name: str, *, index: int | None = None) -> Group:
@@ -91,7 +91,7 @@ DEFAULT_PHASES = ("unit", "stateful")
91
91
  help="Comma-separated list of checks to run against API responses",
92
92
  type=RegistryChoice(CHECKS, with_all=True),
93
93
  default=("not_a_server_error",),
94
- callback=validation.convert_checks,
94
+ callback=validation.reduce_list,
95
95
  show_default=True,
96
96
  metavar="",
97
97
  )
@@ -102,7 +102,7 @@ DEFAULT_PHASES = ("unit", "stateful")
102
102
  help="Comma-separated list of checks to skip during testing",
103
103
  type=RegistryChoice(CHECKS, with_all=True),
104
104
  default=(),
105
- callback=validation.convert_checks,
105
+ callback=validation.reduce_list,
106
106
  show_default=True,
107
107
  metavar="",
108
108
  )
@@ -356,7 +356,7 @@ DEFAULT_PHASES = ("unit", "stateful")
356
356
  help="Guide input generation to values more likely to expose bugs via targeted property-based testing",
357
357
  type=RegistryChoice(TARGETS),
358
358
  default=None,
359
- callback=validation.convert_checks,
359
+ callback=validation.reduce_list,
360
360
  show_default=True,
361
361
  metavar="TARGET",
362
362
  )
@@ -588,7 +588,7 @@ def run(
588
588
  max_response_time=max_response_time,
589
589
  ).into()
590
590
 
591
- if exit_first:
591
+ if exit_first and max_failures is None:
592
592
  max_failures = 1
593
593
 
594
594
  cassette_config = None
@@ -3,7 +3,7 @@ import uuid
3
3
 
4
4
  from schemathesis.core import Specification
5
5
  from schemathesis.engine import events
6
- from schemathesis.schemas import ApiOperationsCount
6
+ from schemathesis.schemas import ApiStatistic
7
7
 
8
8
 
9
9
  class LoadingStarted(events.EngineEvent):
@@ -16,15 +16,28 @@ class LoadingStarted(events.EngineEvent):
16
16
 
17
17
 
18
18
  class LoadingFinished(events.EngineEvent):
19
- __slots__ = ("id", "timestamp", "location", "duration", "base_url", "specification", "operations_count")
19
+ __slots__ = (
20
+ "id",
21
+ "timestamp",
22
+ "location",
23
+ "duration",
24
+ "base_url",
25
+ "base_path",
26
+ "specification",
27
+ "statistic",
28
+ "schema",
29
+ )
20
30
 
21
31
  def __init__(
22
32
  self,
33
+ *,
23
34
  location: str,
24
35
  start_time: float,
25
36
  base_url: str,
37
+ base_path: str,
26
38
  specification: Specification,
27
- operations_count: ApiOperationsCount,
39
+ statistic: ApiStatistic,
40
+ schema: dict,
28
41
  ) -> None:
29
42
  self.id = uuid.uuid4()
30
43
  self.timestamp = time.time()
@@ -32,4 +45,6 @@ class LoadingFinished(events.EngineEvent):
32
45
  self.duration = self.timestamp - start_time
33
46
  self.base_url = base_url
34
47
  self.specification = specification
35
- self.operations_count = operations_count
48
+ self.statistic = statistic
49
+ self.schema = schema
50
+ self.base_path = base_path
@@ -19,7 +19,7 @@ from schemathesis.core.errors import LoaderError
19
19
  from schemathesis.core.output import OutputConfig
20
20
  from schemathesis.engine import from_schema
21
21
  from schemathesis.engine.config import EngineConfig
22
- from schemathesis.engine.events import EventGenerator, FatalError
22
+ from schemathesis.engine.events import EventGenerator, FatalError, Interrupted
23
23
  from schemathesis.filters import FilterSet
24
24
 
25
25
  CUSTOM_HANDLERS: list[type[EventHandler]] = []
@@ -70,6 +70,9 @@ def into_event_stream(config: RunConfig) -> EventGenerator:
70
70
  try:
71
71
  schema = load_schema(loader_config)
72
72
  schema.filter_set = config.filter_set
73
+ except KeyboardInterrupt:
74
+ yield Interrupted(phase=None)
75
+ return
73
76
  except LoaderError as exc:
74
77
  yield FatalError(exception=exc)
75
78
  return
@@ -79,7 +82,9 @@ def into_event_stream(config: RunConfig) -> EventGenerator:
79
82
  start_time=loading_started.timestamp,
80
83
  base_url=schema.get_base_url(),
81
84
  specification=schema.specification,
82
- operations_count=schema.count_operations(),
85
+ statistic=schema.statistic,
86
+ schema=schema.raw_schema,
87
+ base_path=schema.base_path,
83
88
  )
84
89
 
85
90
  try:
@@ -101,6 +106,7 @@ def _execute(event_stream: EventGenerator, config: RunConfig) -> None:
101
106
  handlers.append(
102
107
  OutputHandler(
103
108
  workers_num=config.engine.execution.workers_num,
109
+ seed=config.engine.execution.seed,
104
110
  rate_limit=config.rate_limit,
105
111
  wait_for_schema=config.wait_for_schema,
106
112
  cassette_config=config.cassette,
@@ -112,7 +118,7 @@ def _execute(event_stream: EventGenerator, config: RunConfig) -> None:
112
118
 
113
119
  def shutdown() -> None:
114
120
  for _handler in handlers:
115
- _handler.shutdown()
121
+ _handler.shutdown(ctx)
116
122
 
117
123
  for handler in handlers:
118
124
  handler.start(ctx)
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Callable, Literal, Sequence
4
+ from typing import Any, Callable, Literal, Sequence
5
5
 
6
6
  import click
7
7
 
8
8
  from schemathesis.cli.ext.groups import grouped_option
9
+ from schemathesis.core.errors import IncorrectUsage
9
10
  from schemathesis.filters import FilterSet, expression_to_filter_function, is_deprecated
10
11
 
11
12
 
@@ -148,29 +149,24 @@ class FilterArguments:
148
149
  if exclude_by_function:
149
150
  filter_set.exclude(exclude_by_function)
150
151
  for name_ in self.exclude_name:
151
- filter_set.exclude(name=name_)
152
+ apply_exclude_filter(filter_set, "name", name=name_)
152
153
  for method in self.exclude_method:
153
- filter_set.exclude(method=method)
154
+ apply_exclude_filter(filter_set, "method", method=method)
154
155
  for path in self.exclude_path:
155
- filter_set.exclude(path=path)
156
+ apply_exclude_filter(filter_set, "path", path=path)
156
157
  for tag in self.exclude_tag:
157
- filter_set.exclude(tag=tag)
158
+ apply_exclude_filter(filter_set, "tag", tag=tag)
158
159
  for operation_id in self.exclude_operation_id:
159
- filter_set.exclude(operation_id=operation_id)
160
- if (
161
- self.exclude_name_regex
162
- or self.exclude_method_regex
163
- or self.exclude_path_regex
164
- or self.exclude_tag_regex
165
- or self.exclude_operation_id_regex
160
+ apply_exclude_filter(filter_set, "operation-id", operation_id=operation_id)
161
+ for key, value, name in (
162
+ ("name_regex", self.exclude_name_regex, "name-regex"),
163
+ ("method_regex", self.exclude_method_regex, "method-regex"),
164
+ ("path_regex", self.exclude_path_regex, "path-regex"),
165
+ ("tag_regex", self.exclude_tag_regex, "tag-regex"),
166
+ ("operation_id_regex", self.exclude_operation_id_regex, "operation-id-regex"),
166
167
  ):
167
- filter_set.exclude(
168
- name_regex=self.exclude_name_regex,
169
- method_regex=self.exclude_method_regex,
170
- path_regex=self.exclude_path_regex,
171
- tag_regex=self.exclude_tag_regex,
172
- operation_id_regex=self.exclude_operation_id_regex,
173
- )
168
+ if value:
169
+ apply_exclude_filter(filter_set, name, **{key: value})
174
170
 
175
171
  # Exclude deprecated operations
176
172
  if self.exclude_deprecated:
@@ -179,6 +175,18 @@ class FilterArguments:
179
175
  return filter_set
180
176
 
181
177
 
178
+ def apply_exclude_filter(filter_set: FilterSet, option_name: str, **kwargs: Any) -> None:
179
+ """Apply an exclude filter with proper error handling."""
180
+ try:
181
+ filter_set.exclude(**kwargs)
182
+ except IncorrectUsage as e:
183
+ if str(e) == "Filter already exists":
184
+ raise click.UsageError(
185
+ f"Filter for {option_name} already exists. You can't simultaneously include and exclude the same thing."
186
+ ) from None
187
+ raise click.UsageError(str(e)) from None
188
+
189
+
182
190
  def validate_unique_filter(values: Sequence[str], arg_name: str) -> None:
183
191
  if len(values) != len(set(values)):
184
192
  duplicates = ",".join(sorted({value for value in values if values.count(value) > 1}))
@@ -15,4 +15,4 @@ class EventHandler:
15
15
 
16
16
  def start(self, ctx: ExecutionContext) -> None: ...
17
17
 
18
- def shutdown(self) -> None: ...
18
+ def shutdown(self, ctx: ExecutionContext) -> None: ...
@@ -77,10 +77,8 @@ class CassetteWriter(EventHandler):
77
77
  def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
78
78
  if isinstance(event, events.ScenarioFinished):
79
79
  self.queue.put(Process(recorder=event.recorder))
80
- elif isinstance(event, events.EngineFinished):
81
- self.shutdown()
82
80
 
83
- def shutdown(self) -> None:
81
+ def shutdown(self, ctx: ExecutionContext) -> None:
84
82
  self.queue.put(Finalize())
85
83
  self._stop_worker()
86
84