schemathesis 4.0.0a3__py3-none-any.whl → 4.0.0a5__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 (53) hide show
  1. schemathesis/cli/__init__.py +3 -3
  2. schemathesis/cli/commands/run/__init__.py +159 -135
  3. schemathesis/cli/commands/run/checks.py +2 -3
  4. schemathesis/cli/commands/run/context.py +102 -19
  5. schemathesis/cli/commands/run/executor.py +33 -12
  6. schemathesis/cli/commands/run/filters.py +1 -0
  7. schemathesis/cli/commands/run/handlers/cassettes.py +27 -46
  8. schemathesis/cli/commands/run/handlers/junitxml.py +1 -1
  9. schemathesis/cli/commands/run/handlers/output.py +238 -102
  10. schemathesis/cli/commands/run/hypothesis.py +14 -41
  11. schemathesis/cli/commands/run/reports.py +72 -0
  12. schemathesis/cli/commands/run/validation.py +18 -12
  13. schemathesis/cli/ext/groups.py +42 -13
  14. schemathesis/cli/ext/options.py +15 -8
  15. schemathesis/core/__init__.py +7 -1
  16. schemathesis/core/errors.py +79 -11
  17. schemathesis/core/failures.py +2 -1
  18. schemathesis/core/transforms.py +1 -1
  19. schemathesis/engine/config.py +2 -2
  20. schemathesis/engine/core.py +11 -1
  21. schemathesis/engine/errors.py +8 -3
  22. schemathesis/engine/events.py +7 -0
  23. schemathesis/engine/phases/__init__.py +16 -4
  24. schemathesis/engine/phases/stateful/_executor.py +1 -1
  25. schemathesis/engine/phases/unit/__init__.py +77 -53
  26. schemathesis/engine/phases/unit/_executor.py +28 -23
  27. schemathesis/engine/phases/unit/_pool.py +8 -0
  28. schemathesis/errors.py +6 -2
  29. schemathesis/experimental/__init__.py +0 -6
  30. schemathesis/filters.py +8 -0
  31. schemathesis/generation/coverage.py +6 -1
  32. schemathesis/generation/hypothesis/builder.py +222 -97
  33. schemathesis/generation/stateful/state_machine.py +49 -3
  34. schemathesis/openapi/checks.py +3 -1
  35. schemathesis/pytest/lazy.py +43 -5
  36. schemathesis/pytest/plugin.py +4 -4
  37. schemathesis/schemas.py +1 -1
  38. schemathesis/specs/openapi/checks.py +28 -11
  39. schemathesis/specs/openapi/examples.py +2 -5
  40. schemathesis/specs/openapi/expressions/__init__.py +22 -6
  41. schemathesis/specs/openapi/expressions/nodes.py +15 -21
  42. schemathesis/specs/openapi/expressions/parser.py +1 -1
  43. schemathesis/specs/openapi/parameters.py +0 -2
  44. schemathesis/specs/openapi/patterns.py +24 -7
  45. schemathesis/specs/openapi/schemas.py +13 -13
  46. schemathesis/specs/openapi/serialization.py +14 -0
  47. schemathesis/specs/openapi/stateful/__init__.py +96 -23
  48. schemathesis/specs/openapi/{links.py → stateful/links.py} +60 -16
  49. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/METADATA +7 -26
  50. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/RECORD +53 -52
  51. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/WHEEL +0 -0
  52. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/entry_points.txt +0 -0
  53. {schemathesis-4.0.0a3.dist-info → schemathesis-4.0.0a5.dist-info}/licenses/LICENSE +0 -0
@@ -134,7 +134,7 @@ def execute_state_machine_loop(
134
134
  return result
135
135
 
136
136
  def validate_response(
137
- self, response: Response, case: Case, additional_checks: tuple[CheckFunction, ...] = ()
137
+ self, response: Response, case: Case, additional_checks: tuple[CheckFunction, ...] = (), **kwargs: Any
138
138
  ) -> None:
139
139
  self.recorder.record_response(case_id=case.id, response=response)
140
140
  ctx.collect_metric(case, response)
@@ -11,11 +11,12 @@ import warnings
11
11
  from queue import Queue
12
12
  from typing import TYPE_CHECKING, Any
13
13
 
14
+ from schemathesis.core.errors import InvalidSchema
14
15
  from schemathesis.core.result import Ok
15
16
  from schemathesis.engine import Status, events
16
17
  from schemathesis.engine.phases import PhaseName, PhaseSkipReason
17
18
  from schemathesis.engine.recorder import ScenarioRecorder
18
- from schemathesis.generation.hypothesis.builder import HypothesisTestConfig
19
+ from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, HypothesisTestMode
19
20
  from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
20
21
 
21
22
  from ._pool import TaskProducer, WorkerPool
@@ -34,6 +35,12 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
34
35
  Implemented as a producer-consumer pattern via a task queue.
35
36
  The main thread provides an iterator over API operations and worker threads create test functions and run them.
36
37
  """
38
+ if phase.name == PhaseName.EXAMPLES:
39
+ mode = HypothesisTestMode.EXAMPLES
40
+ elif phase.name == PhaseName.COVERAGE:
41
+ mode = HypothesisTestMode.COVERAGE
42
+ else:
43
+ mode = HypothesisTestMode.FUZZING
37
44
  producer = TaskProducer(engine)
38
45
  workers_num = engine.config.execution.workers_num
39
46
 
@@ -45,7 +52,13 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
45
52
  is_executed = False
46
53
 
47
54
  with WorkerPool(
48
- workers_num=workers_num, producer=producer, worker_factory=worker_task, ctx=engine, suite_id=suite_started.id
55
+ workers_num=workers_num,
56
+ producer=producer,
57
+ worker_factory=worker_task,
58
+ ctx=engine,
59
+ mode=mode,
60
+ phase=phase.name,
61
+ suite_id=suite_started.id,
49
62
  ) as pool:
50
63
  try:
51
64
  while True:
@@ -74,7 +87,7 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
74
87
  except KeyboardInterrupt:
75
88
  engine.stop()
76
89
  status = Status.INTERRUPTED
77
- yield events.Interrupted(phase=PhaseName.UNIT_TESTING)
90
+ yield events.Interrupted(phase=phase.name)
78
91
 
79
92
  if not is_executed:
80
93
  phase.skip_reason = PhaseSkipReason.NOTHING_TO_TEST
@@ -86,13 +99,52 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
86
99
  yield events.PhaseFinished(phase=phase, status=status, payload=None)
87
100
 
88
101
 
89
- def worker_task(*, events_queue: Queue, producer: TaskProducer, ctx: EngineContext, suite_id: uuid.UUID) -> None:
90
- from hypothesis.errors import HypothesisWarning
102
+ def worker_task(
103
+ *,
104
+ events_queue: Queue,
105
+ producer: TaskProducer,
106
+ ctx: EngineContext,
107
+ mode: HypothesisTestMode,
108
+ phase: PhaseName,
109
+ suite_id: uuid.UUID,
110
+ ) -> None:
111
+ from hypothesis.errors import HypothesisWarning, InvalidArgument
91
112
 
92
113
  from schemathesis.generation.hypothesis.builder import create_test
93
114
 
94
115
  from ._executor import run_test, test_func
95
116
 
117
+ def on_error(error: Exception, *, method: str | None = None, path: str | None = None) -> None:
118
+ if method and path:
119
+ label = f"{method.upper()} {path}"
120
+ scenario_started = events.ScenarioStarted(label=label, phase=phase, suite_id=suite_id)
121
+ events_queue.put(scenario_started)
122
+
123
+ events_queue.put(events.NonFatalError(error=error, phase=phase, label=label, related_to_operation=True))
124
+
125
+ events_queue.put(
126
+ events.ScenarioFinished(
127
+ id=scenario_started.id,
128
+ suite_id=suite_id,
129
+ phase=phase,
130
+ label=label,
131
+ status=Status.ERROR,
132
+ recorder=ScenarioRecorder(label="Error"),
133
+ elapsed_time=0.0,
134
+ skip_reason=None,
135
+ is_final=True,
136
+ )
137
+ )
138
+ else:
139
+ events_queue.put(
140
+ events.NonFatalError(
141
+ error=error,
142
+ phase=phase,
143
+ label=path or "-",
144
+ related_to_operation=False,
145
+ )
146
+ )
147
+
96
148
  warnings.filterwarnings("ignore", message="The recursion limit will not be reset", category=HypothesisWarning)
97
149
  with ignore_hypothesis_output():
98
150
  try:
@@ -104,62 +156,34 @@ def worker_task(*, events_queue: Queue, producer: TaskProducer, ctx: EngineConte
104
156
  if isinstance(result, Ok):
105
157
  operation = result.ok()
106
158
  as_strategy_kwargs = get_strategy_kwargs(ctx, operation)
107
- test_function = create_test(
108
- operation=operation,
109
- test_func=test_func,
110
- config=HypothesisTestConfig(
111
- settings=ctx.config.execution.hypothesis_settings,
112
- seed=ctx.config.execution.seed,
113
- generation=ctx.config.execution.generation,
114
- as_strategy_kwargs=as_strategy_kwargs,
115
- ),
116
- )
159
+ try:
160
+ test_function = create_test(
161
+ operation=operation,
162
+ test_func=test_func,
163
+ config=HypothesisTestConfig(
164
+ modes=[mode],
165
+ settings=ctx.config.execution.hypothesis_settings,
166
+ seed=ctx.config.execution.seed,
167
+ generation=ctx.config.execution.generation,
168
+ as_strategy_kwargs=as_strategy_kwargs,
169
+ ),
170
+ )
171
+ except (InvalidSchema, InvalidArgument) as exc:
172
+ on_error(exc, method=operation.method, path=operation.path)
173
+ continue
117
174
 
118
175
  # The test is blocking, meaning that even if CTRL-C comes to the main thread, this tasks will continue
119
176
  # executing. However, as we set a stop event, it will be checked before the next network request.
120
177
  # However, this is still suboptimal, as there could be slow requests and they will block for longer
121
- for event in run_test(operation=operation, test_function=test_function, ctx=ctx, suite_id=suite_id):
178
+ for event in run_test(
179
+ operation=operation, test_function=test_function, ctx=ctx, phase=phase, suite_id=suite_id
180
+ ):
122
181
  events_queue.put(event)
123
182
  else:
124
183
  error = result.err()
125
- if error.method:
126
- label = f"{error.method.upper()} {error.full_path}"
127
- scenario_started = events.ScenarioStarted(
128
- label=label, phase=PhaseName.UNIT_TESTING, suite_id=suite_id
129
- )
130
- events_queue.put(scenario_started)
131
-
132
- events_queue.put(
133
- events.NonFatalError(
134
- error=error, phase=PhaseName.UNIT_TESTING, label=label, related_to_operation=True
135
- )
136
- )
137
-
138
- events_queue.put(
139
- events.ScenarioFinished(
140
- id=scenario_started.id,
141
- suite_id=suite_id,
142
- phase=PhaseName.UNIT_TESTING,
143
- label=label,
144
- status=Status.ERROR,
145
- recorder=ScenarioRecorder(label="Error"),
146
- elapsed_time=0.0,
147
- skip_reason=None,
148
- is_final=True,
149
- )
150
- )
151
- else:
152
- assert error.full_path is not None
153
- events_queue.put(
154
- events.NonFatalError(
155
- error=error,
156
- phase=PhaseName.UNIT_TESTING,
157
- label=error.full_path,
158
- related_to_operation=False,
159
- )
160
- )
184
+ on_error(error, method=error.method, path=error.path)
161
185
  except KeyboardInterrupt:
162
- events_queue.put(events.Interrupted(phase=PhaseName.UNIT_TESTING))
186
+ events_queue.put(events.Interrupted(phase=phase))
163
187
 
164
188
 
165
189
  def get_strategy_kwargs(ctx: EngineContext, operation: APIOperation) -> dict[str, Any]:
@@ -52,13 +52,17 @@ if TYPE_CHECKING:
52
52
 
53
53
 
54
54
  def run_test(
55
- *, operation: APIOperation, test_function: Callable, ctx: EngineContext, suite_id: uuid.UUID
55
+ *,
56
+ operation: APIOperation,
57
+ test_function: Callable,
58
+ ctx: EngineContext,
59
+ phase: PhaseName,
60
+ suite_id: uuid.UUID,
56
61
  ) -> events.EventGenerator:
57
62
  """A single test run with all error handling needed."""
58
63
  import hypothesis.errors
59
64
 
60
- # To simplify connecting `before` and `after` events in external systems
61
- scenario_started = events.ScenarioStarted(label=operation.label, phase=PhaseName.UNIT_TESTING, suite_id=suite_id)
65
+ scenario_started = events.ScenarioStarted(label=operation.label, phase=phase, suite_id=suite_id)
62
66
  yield scenario_started
63
67
  errors: list[Exception] = []
64
68
  skip_reason = None
@@ -66,8 +70,19 @@ def run_test(
66
70
  recorder = ScenarioRecorder(label=operation.label)
67
71
 
68
72
  def non_fatal_error(error: Exception) -> events.NonFatalError:
69
- return events.NonFatalError(
70
- error=error, phase=PhaseName.UNIT_TESTING, label=operation.label, related_to_operation=True
73
+ return events.NonFatalError(error=error, phase=phase, label=operation.label, related_to_operation=True)
74
+
75
+ def scenario_finished(status: Status) -> events.ScenarioFinished:
76
+ return events.ScenarioFinished(
77
+ id=scenario_started.id,
78
+ suite_id=suite_id,
79
+ phase=phase,
80
+ label=operation.label,
81
+ recorder=recorder,
82
+ status=status,
83
+ elapsed_time=time.monotonic() - test_start_time,
84
+ skip_reason=skip_reason,
85
+ is_final=False,
71
86
  )
72
87
 
73
88
  try:
@@ -111,7 +126,8 @@ def run_test(
111
126
  status = Status.ERROR
112
127
  yield non_fatal_error(hypothesis.errors.Unsatisfiable("Failed to generate test cases for this API operation"))
113
128
  except KeyboardInterrupt:
114
- yield events.Interrupted(phase=PhaseName.UNIT_TESTING)
129
+ yield scenario_finished(Status.INTERRUPTED)
130
+ yield events.Interrupted(phase=phase)
115
131
  return
116
132
  except AssertionError as exc: # May come from `hypothesis-jsonschema` or `hypothesis`
117
133
  status = Status.ERROR
@@ -131,7 +147,6 @@ def run_test(
131
147
  exc,
132
148
  path=operation.path,
133
149
  method=operation.method,
134
- full_path=operation.schema.get_full_path(operation.path),
135
150
  )
136
151
  )
137
152
  except HypothesisRefResolutionError:
@@ -165,7 +180,7 @@ def run_test(
165
180
  yield non_fatal_error(exc)
166
181
  if (
167
182
  status == Status.SUCCESS
168
- and ctx.config.execution.no_failfast
183
+ and ctx.config.execution.continue_on_failure
169
184
  and any(check.status == Status.FAILURE for checks in recorder.checks.values() for check in checks)
170
185
  ):
171
186
  status = Status.FAILURE
@@ -194,20 +209,10 @@ def run_test(
194
209
  if invalid_headers:
195
210
  status = Status.ERROR
196
211
  yield non_fatal_error(InvalidHeadersExample.from_headers(invalid_headers))
197
- test_elapsed_time = time.monotonic() - test_start_time
198
212
  for error in deduplicate_errors(errors):
199
213
  yield non_fatal_error(error)
200
- yield events.ScenarioFinished(
201
- id=scenario_started.id,
202
- suite_id=suite_id,
203
- phase=PhaseName.UNIT_TESTING,
204
- label=operation.label,
205
- recorder=recorder,
206
- status=status,
207
- elapsed_time=test_elapsed_time,
208
- skip_reason=skip_reason,
209
- is_final=False,
210
- )
214
+
215
+ yield scenario_finished(status)
211
216
 
212
217
 
213
218
  def setup_hypothesis_database_key(test: Callable, operation: APIOperation) -> None:
@@ -280,7 +285,7 @@ def test_func(*, ctx: EngineContext, case: Case, recorder: ScenarioRecorder) ->
280
285
  ctx=ctx.get_check_context(recorder),
281
286
  checks=ctx.config.execution.checks,
282
287
  response=response,
283
- no_failfast=ctx.config.execution.no_failfast,
288
+ continue_on_failure=ctx.config.execution.continue_on_failure,
284
289
  recorder=recorder,
285
290
  )
286
291
 
@@ -291,7 +296,7 @@ def validate_response(
291
296
  ctx: CheckContext,
292
297
  checks: Iterable[CheckFunction],
293
298
  response: Response,
294
- no_failfast: bool,
299
+ continue_on_failure: bool,
295
300
  recorder: ScenarioRecorder,
296
301
  ) -> None:
297
302
  failures = set()
@@ -318,5 +323,5 @@ def validate_response(
318
323
  on_success=on_success,
319
324
  )
320
325
 
321
- if failures and not no_failfast:
326
+ if failures and not continue_on_failure:
322
327
  raise FailureGroup(list(failures)) from None
@@ -7,9 +7,11 @@ from types import TracebackType
7
7
  from typing import TYPE_CHECKING, Callable
8
8
 
9
9
  from schemathesis.core.result import Result
10
+ from schemathesis.engine.phases import PhaseName
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  from schemathesis.engine.context import EngineContext
14
+ from schemathesis.generation.hypothesis.builder import HypothesisTestMode
13
15
 
14
16
 
15
17
  class TaskProducer:
@@ -34,12 +36,16 @@ class WorkerPool:
34
36
  producer: TaskProducer,
35
37
  worker_factory: Callable,
36
38
  ctx: EngineContext,
39
+ mode: HypothesisTestMode,
40
+ phase: PhaseName,
37
41
  suite_id: uuid.UUID,
38
42
  ) -> None:
39
43
  self.workers_num = workers_num
40
44
  self.producer = producer
41
45
  self.worker_factory = worker_factory
42
46
  self.ctx = ctx
47
+ self.mode = mode
48
+ self.phase = phase
43
49
  self.suite_id = suite_id
44
50
  self.workers: list[threading.Thread] = []
45
51
  self.events_queue: Queue = Queue()
@@ -51,6 +57,8 @@ class WorkerPool:
51
57
  target=self.worker_factory,
52
58
  kwargs={
53
59
  "ctx": self.ctx,
60
+ "mode": self.mode,
61
+ "phase": self.phase,
54
62
  "events_queue": self.events_queue,
55
63
  "producer": self.producer,
56
64
  "suite_id": self.suite_id,
schemathesis/errors.py CHANGED
@@ -4,17 +4,19 @@ from schemathesis.core.errors import (
4
4
  IncorrectUsage,
5
5
  InternalError,
6
6
  InvalidHeadersExample,
7
- InvalidLinkDefinition,
8
7
  InvalidRateLimit,
9
8
  InvalidRegexPattern,
10
9
  InvalidRegexType,
11
10
  InvalidSchema,
11
+ InvalidStateMachine,
12
+ InvalidTransition,
12
13
  LoaderError,
13
14
  NoLinksFound,
14
15
  OperationNotFound,
15
16
  SchemathesisError,
16
17
  SerializationError,
17
18
  SerializationNotPossible,
19
+ TransitionValidationError,
18
20
  UnboundPrefix,
19
21
  )
20
22
 
@@ -22,16 +24,18 @@ __all__ = [
22
24
  "IncorrectUsage",
23
25
  "InternalError",
24
26
  "InvalidHeadersExample",
25
- "InvalidLinkDefinition",
26
27
  "InvalidRateLimit",
27
28
  "InvalidRegexPattern",
28
29
  "InvalidRegexType",
29
30
  "InvalidSchema",
31
+ "InvalidStateMachine",
32
+ "InvalidTransition",
30
33
  "LoaderError",
31
34
  "OperationNotFound",
32
35
  "NoLinksFound",
33
36
  "SchemathesisError",
34
37
  "SerializationError",
35
38
  "SerializationNotPossible",
39
+ "TransitionValidationError",
36
40
  "UnboundPrefix",
37
41
  ]
@@ -64,12 +64,6 @@ class ExperimentSet:
64
64
 
65
65
  ENV_PREFIX = "SCHEMATHESIS_EXPERIMENTAL"
66
66
  GLOBAL_EXPERIMENTS = ExperimentSet()
67
- COVERAGE_PHASE = GLOBAL_EXPERIMENTS.create_experiment(
68
- name="Coverage phase",
69
- env_var="COVERAGE_PHASE",
70
- description="Generate covering test cases",
71
- discussion_url="https://github.com/schemathesis/schemathesis/discussions/2418",
72
- )
73
67
  POSITIVE_DATA_ACCEPTANCE = GLOBAL_EXPERIMENTS.create_experiment(
74
68
  name="Positive Data Acceptance",
75
69
  env_var="POSITIVE_DATA_ACCEPTANCE",
schemathesis/filters.py CHANGED
@@ -268,6 +268,8 @@ class FilterSet:
268
268
  # To match anything the regex should match the expected value, hence passing them together is useless
269
269
  raise IncorrectUsage(ERROR_EXPECTED_AND_REGEX)
270
270
  if expected is not None:
271
+ if attribute == "method":
272
+ expected = _normalize_method(expected)
271
273
  matchers.append(Matcher.for_value(attribute, expected))
272
274
  if regex is not None:
273
275
  matchers.append(Matcher.for_regex(attribute, regex))
@@ -283,6 +285,12 @@ class FilterSet:
283
285
  self._excludes.add(filter_)
284
286
 
285
287
 
288
+ def _normalize_method(value: FilterValue) -> FilterValue:
289
+ if isinstance(value, list):
290
+ return [item.upper() for item in value]
291
+ return value.upper()
292
+
293
+
286
294
  def attach_filter_chain(
287
295
  target: Callable,
288
296
  attribute: str,
@@ -437,7 +437,12 @@ def cover_schema_iter(
437
437
  elif key == "required":
438
438
  template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
439
439
  yield from _negative_required(ctx, template, value)
440
- elif key == "additionalProperties" and not value and "pattern" not in schema:
440
+ elif (
441
+ key == "additionalProperties"
442
+ and not value
443
+ and "pattern" not in schema
444
+ and schema.get("type") in ["object", None]
445
+ ):
441
446
  template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
442
447
  yield NegativeValue(
443
448
  {**template, UNKNOWN_PROPERTY_KEY: UNKNOWN_PROPERTY_VALUE},