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
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import sys
4
- from dataclasses import dataclass
5
4
  from typing import Any, Callable
6
5
 
7
6
  import click
@@ -13,15 +12,13 @@ from schemathesis.cli.commands.run.handlers.base import EventHandler
13
12
  from schemathesis.cli.commands.run.handlers.cassettes import CassetteWriter
14
13
  from schemathesis.cli.commands.run.handlers.junitxml import JunitXMLHandler
15
14
  from schemathesis.cli.commands.run.handlers.output import OutputHandler
16
- from schemathesis.cli.commands.run.loaders import AutodetectConfig, load_schema
17
- from schemathesis.cli.commands.run.reports import ReportConfig, ReportFormat
15
+ from schemathesis.cli.commands.run.loaders import load_schema
18
16
  from schemathesis.cli.ext.fs import open_file
17
+ from schemathesis.config import ProjectConfig, ReportFormat
19
18
  from schemathesis.core.errors import LoaderError
20
- from schemathesis.core.output import OutputConfig
19
+ from schemathesis.core.fs import file_exists
21
20
  from schemathesis.engine import from_schema
22
- from schemathesis.engine.config import EngineConfig
23
21
  from schemathesis.engine.events import EventGenerator, FatalError, Interrupted
24
- from schemathesis.filters import FilterSet
25
22
 
26
23
  CUSTOM_HANDLERS: list[type[EventHandler]] = []
27
24
 
@@ -35,41 +32,34 @@ def handler() -> Callable[[type], None]:
35
32
  return _wrapper
36
33
 
37
34
 
38
- @dataclass
39
- class RunConfig:
40
- location: str
41
- base_url: str | None
42
- filter_set: FilterSet
43
- engine: EngineConfig
44
- wait_for_schema: float | None
45
- rate_limit: str | None
46
- output: OutputConfig
47
- report: ReportConfig | None
48
- args: list[str]
49
- params: dict[str, Any]
50
-
51
-
52
- def execute(config: RunConfig) -> None:
53
- event_stream = into_event_stream(config)
54
- _execute(event_stream, config)
55
-
56
-
57
- def into_event_stream(config: RunConfig) -> EventGenerator:
58
- loader_config = AutodetectConfig(
59
- location=config.location,
60
- network=config.engine.network,
61
- wait_for_schema=config.wait_for_schema,
62
- base_url=config.base_url,
63
- rate_limit=config.rate_limit,
64
- output=config.output,
65
- generation=config.engine.execution.generation,
66
- )
67
- loading_started = LoadingStarted(location=config.location)
35
+ def execute(
36
+ *,
37
+ location: str,
38
+ config: ProjectConfig,
39
+ filter_set: dict[str, Any],
40
+ args: list[str],
41
+ params: dict[str, Any],
42
+ ) -> None:
43
+ event_stream = into_event_stream(location=location, config=config, filter_set=filter_set)
44
+ _execute(event_stream, config=config, args=args, params=params)
45
+
46
+
47
+ MISSING_BASE_URL_MESSAGE = "The `--url` option is required when specifying a schema via a file."
48
+
49
+
50
+ def into_event_stream(*, location: str, config: ProjectConfig, filter_set: dict[str, Any]) -> EventGenerator:
51
+ # The whole engine idea is that it communicates with the outside via events, so handlers can react to them
52
+ # For this reason, even schema loading is done via a separate set of events.
53
+ loading_started = LoadingStarted(location=location)
68
54
  yield loading_started
69
55
 
70
56
  try:
71
- schema = load_schema(loader_config)
72
- schema.filter_set = config.filter_set
57
+ schema = load_schema(location=location, config=config)
58
+ # Schemas don't (yet?) use configs for deciding what operations should be tested, so
59
+ # a separate FilterSet passed there. It combines both config file filters + CLI options
60
+ schema.filter_set = schema.config.operations.create_filter_set(**filter_set)
61
+ if file_exists(location) and schema.config.base_url is None:
62
+ raise click.UsageError(MISSING_BASE_URL_MESSAGE)
73
63
  except KeyboardInterrupt:
74
64
  yield Interrupted(phase=None)
75
65
  return
@@ -78,73 +68,75 @@ def into_event_stream(config: RunConfig) -> EventGenerator:
78
68
  return
79
69
 
80
70
  yield LoadingFinished(
81
- location=config.location,
71
+ location=location,
82
72
  start_time=loading_started.timestamp,
83
73
  base_url=schema.get_base_url(),
84
74
  specification=schema.specification,
85
75
  statistic=schema.statistic,
86
76
  schema=schema.raw_schema,
77
+ config=schema.config,
87
78
  base_path=schema.base_path,
79
+ find_operation_by_label=schema.find_operation_by_label,
88
80
  )
89
81
 
90
82
  try:
91
- yield from from_schema(schema, config=config.engine).execute()
83
+ yield from from_schema(schema).execute()
92
84
  except Exception as exc:
93
85
  yield FatalError(exception=exc)
94
86
 
95
87
 
96
- def initialize_handlers(config: RunConfig) -> list[EventHandler]:
88
+ def initialize_handlers(
89
+ *,
90
+ config: ProjectConfig,
91
+ args: list[str],
92
+ params: dict[str, Any],
93
+ ) -> list[EventHandler]:
97
94
  """Create event handlers based on run configuration."""
98
95
  handlers: list[EventHandler] = []
99
96
 
100
- if config.report is not None:
101
- if ReportFormat.JUNIT in config.report.formats:
102
- path = config.report.get_path(ReportFormat.JUNIT)
97
+ if config.reports.junit.enabled:
98
+ path = config.reports.get_path(ReportFormat.JUNIT)
99
+ open_file(path)
100
+ handlers.append(JunitXMLHandler(path))
101
+ for format, report in (
102
+ (ReportFormat.VCR, config.reports.vcr),
103
+ (ReportFormat.HAR, config.reports.har),
104
+ ):
105
+ if report.enabled:
106
+ path = config.reports.get_path(format)
103
107
  open_file(path)
104
- handlers.append(JunitXMLHandler(path))
105
-
106
- for format in (ReportFormat.VCR, ReportFormat.HAR):
107
- if format in config.report.formats:
108
- path = config.report.get_path(format)
109
- open_file(path)
110
- handlers.append(
111
- CassetteWriter(
112
- format=format,
113
- path=path,
114
- sanitize_output=config.report.sanitize_output,
115
- preserve_bytes=config.report.preserve_bytes,
116
- )
117
- )
108
+ handlers.append(CassetteWriter(format=format, path=path, config=config))
118
109
 
119
110
  for custom_handler in CUSTOM_HANDLERS:
120
- handlers.append(custom_handler(*config.args, **config.params))
121
-
122
- handlers.append(
123
- OutputHandler(
124
- workers_num=config.engine.execution.workers_num,
125
- seed=config.engine.execution.seed,
126
- rate_limit=config.rate_limit,
127
- wait_for_schema=config.wait_for_schema,
128
- engine_config=config.engine,
129
- report_config=config.report,
130
- )
131
- )
111
+ handlers.append(custom_handler(*args, **params))
112
+
113
+ handlers.append(OutputHandler(config=config))
132
114
 
133
115
  return handlers
134
116
 
135
117
 
136
- def _execute(event_stream: EventGenerator, config: RunConfig) -> None:
137
- handlers = initialize_handlers(config)
138
- ctx = ExecutionContext(output_config=config.output, seed=config.engine.execution.seed)
118
+ def _execute(
119
+ event_stream: EventGenerator,
120
+ *,
121
+ config: ProjectConfig,
122
+ args: list[str],
123
+ params: dict[str, Any],
124
+ ) -> None:
125
+ handlers: list[EventHandler] = []
126
+ ctx: ExecutionContext | None = None
139
127
 
140
128
  def shutdown() -> None:
141
- for _handler in handlers:
142
- _handler.shutdown(ctx)
143
-
144
- for handler in handlers:
145
- handler.start(ctx)
129
+ if ctx is not None:
130
+ for _handler in handlers:
131
+ _handler.shutdown(ctx)
146
132
 
147
133
  try:
134
+ handlers = initialize_handlers(config=config, args=args, params=params)
135
+ ctx = ExecutionContext(config=config)
136
+
137
+ for handler in handlers:
138
+ handler.start(ctx)
139
+
148
140
  for event in event_stream:
149
141
  ctx.on_event(event)
150
142
  for handler in handlers:
@@ -1,13 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
4
- from typing import Any, Callable, Literal, Sequence
3
+ from functools import partial
4
+ from typing import Callable, Literal
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
10
- from schemathesis.filters import FilterSet, expression_to_filter_function, is_deprecated
11
9
 
12
10
 
13
11
  def _with_filter(*, by: str, mode: Literal["include", "exclude"], modifier: Literal["regex"] | None) -> Callable:
@@ -18,19 +16,32 @@ def _with_filter(*, by: str, mode: Literal["include", "exclude"], modifier: Lite
18
16
  "operation-id": "ID",
19
17
  "name": "Operation name",
20
18
  }.get(by, by.capitalize())
19
+ callback = None
21
20
  if modifier:
22
21
  param += f"-{modifier}"
23
22
  prop += " pattern"
23
+ else:
24
+ callback = partial(validate_filter, arg_name=param)
24
25
  help_text = f"{prop} to {action} testing."
25
26
  return grouped_option(
26
27
  param,
27
28
  help=help_text,
28
29
  type=str,
29
30
  multiple=modifier is None,
31
+ callback=callback,
30
32
  hidden=True,
31
33
  )
32
34
 
33
35
 
36
+ def validate_filter(
37
+ ctx: click.core.Context, param: click.core.Parameter, raw_value: list[str], arg_name: str
38
+ ) -> list[str]:
39
+ if len(raw_value) != len(set(raw_value)):
40
+ duplicates = ",".join(sorted({value for value in raw_value if raw_value.count(value) > 1}))
41
+ raise click.UsageError(f"Duplicate values are not allowed for `{arg_name}`: {duplicates}")
42
+ return raw_value
43
+
44
+
34
45
  _BY_VALUES = ("operation-id", "tag", "name", "method", "path")
35
46
 
36
47
 
@@ -40,164 +51,3 @@ def with_filters(command: Callable) -> Callable:
40
51
  for modifier in ("regex", None):
41
52
  command = _with_filter(by=by, mode=mode, modifier=modifier)(command) # type: ignore[arg-type]
42
53
  return command
43
-
44
-
45
- @dataclass
46
- class FilterArguments:
47
- include_path: Sequence[str]
48
- include_method: Sequence[str]
49
- include_name: Sequence[str]
50
- include_tag: Sequence[str]
51
- include_operation_id: Sequence[str]
52
- include_path_regex: str | None
53
- include_method_regex: str | None
54
- include_name_regex: str | None
55
- include_tag_regex: str | None
56
- include_operation_id_regex: str | None
57
-
58
- exclude_path: Sequence[str]
59
- exclude_method: Sequence[str]
60
- exclude_name: Sequence[str]
61
- exclude_tag: Sequence[str]
62
- exclude_operation_id: Sequence[str]
63
- exclude_path_regex: str | None
64
- exclude_method_regex: str | None
65
- exclude_name_regex: str | None
66
- exclude_tag_regex: str | None
67
- exclude_operation_id_regex: str | None
68
-
69
- include_by: str | None
70
- exclude_by: str | None
71
- exclude_deprecated: bool
72
-
73
- __slots__ = (
74
- "include_path",
75
- "include_method",
76
- "include_name",
77
- "include_tag",
78
- "include_operation_id",
79
- "include_path_regex",
80
- "include_method_regex",
81
- "include_name_regex",
82
- "include_tag_regex",
83
- "include_operation_id_regex",
84
- "exclude_path",
85
- "exclude_method",
86
- "exclude_name",
87
- "exclude_tag",
88
- "exclude_operation_id",
89
- "exclude_path_regex",
90
- "exclude_method_regex",
91
- "exclude_name_regex",
92
- "exclude_tag_regex",
93
- "exclude_operation_id_regex",
94
- "include_by",
95
- "exclude_by",
96
- "exclude_deprecated",
97
- )
98
-
99
- def into(self) -> FilterSet:
100
- # Validate unique filter arguments
101
- for values, arg_name in (
102
- (self.include_path, "--include-path"),
103
- (self.include_method, "--include-method"),
104
- (self.include_name, "--include-name"),
105
- (self.include_tag, "--include-tag"),
106
- (self.include_operation_id, "--include-operation-id"),
107
- (self.exclude_path, "--exclude-path"),
108
- (self.exclude_method, "--exclude-method"),
109
- (self.exclude_name, "--exclude-name"),
110
- (self.exclude_tag, "--exclude-tag"),
111
- (self.exclude_operation_id, "--exclude-operation-id"),
112
- ):
113
- validate_unique_filter(values, arg_name)
114
-
115
- # Convert include/exclude expressions to functions
116
- include_by_function = _filter_by_expression_to_func(self.include_by, "--include-by")
117
- exclude_by_function = _filter_by_expression_to_func(self.exclude_by, "--exclude-by")
118
-
119
- filter_set = FilterSet()
120
-
121
- # Apply include filters
122
- if include_by_function:
123
- filter_set.include(include_by_function)
124
- for name_ in self.include_name:
125
- filter_set.include(name=name_)
126
- for method in self.include_method:
127
- filter_set.include(method=method)
128
- for path in self.include_path:
129
- filter_set.include(path=path)
130
- for tag in self.include_tag:
131
- filter_set.include(tag=tag)
132
- for operation_id in self.include_operation_id:
133
- filter_set.include(operation_id=operation_id)
134
- if (
135
- self.include_name_regex
136
- or self.include_method_regex
137
- or self.include_path_regex
138
- or self.include_tag_regex
139
- or self.include_operation_id_regex
140
- ):
141
- filter_set.include(
142
- name_regex=self.include_name_regex,
143
- method_regex=self.include_method_regex,
144
- path_regex=self.include_path_regex,
145
- tag_regex=self.include_tag_regex,
146
- operation_id_regex=self.include_operation_id_regex,
147
- )
148
-
149
- # Apply exclude filters
150
- if exclude_by_function:
151
- filter_set.exclude(exclude_by_function)
152
- for name_ in self.exclude_name:
153
- apply_exclude_filter(filter_set, "name", name=name_)
154
- for method in self.exclude_method:
155
- apply_exclude_filter(filter_set, "method", method=method)
156
- for path in self.exclude_path:
157
- apply_exclude_filter(filter_set, "path", path=path)
158
- for tag in self.exclude_tag:
159
- apply_exclude_filter(filter_set, "tag", tag=tag)
160
- for operation_id in self.exclude_operation_id:
161
- apply_exclude_filter(filter_set, "operation-id", operation_id=operation_id)
162
- for key, value, name in (
163
- ("name_regex", self.exclude_name_regex, "name-regex"),
164
- ("method_regex", self.exclude_method_regex, "method-regex"),
165
- ("path_regex", self.exclude_path_regex, "path-regex"),
166
- ("tag_regex", self.exclude_tag_regex, "tag-regex"),
167
- ("operation_id_regex", self.exclude_operation_id_regex, "operation-id-regex"),
168
- ):
169
- if value:
170
- apply_exclude_filter(filter_set, name, **{key: value})
171
-
172
- # Exclude deprecated operations
173
- if self.exclude_deprecated:
174
- filter_set.exclude(is_deprecated)
175
-
176
- return filter_set
177
-
178
-
179
- def apply_exclude_filter(filter_set: FilterSet, option_name: str, **kwargs: Any) -> None:
180
- """Apply an exclude filter with proper error handling."""
181
- try:
182
- filter_set.exclude(**kwargs)
183
- except IncorrectUsage as e:
184
- if str(e) == "Filter already exists":
185
- raise click.UsageError(
186
- f"Filter for {option_name} already exists. You can't simultaneously include and exclude the same thing."
187
- ) from None
188
- raise click.UsageError(str(e)) from None
189
-
190
-
191
- def validate_unique_filter(values: Sequence[str], arg_name: str) -> None:
192
- if len(values) != len(set(values)):
193
- duplicates = ",".join(sorted({value for value in values if values.count(value) > 1}))
194
- raise click.UsageError(f"Duplicate values are not allowed for `{arg_name}`: {duplicates}")
195
-
196
-
197
- def _filter_by_expression_to_func(value: str | None, arg_name: str) -> Callable | None:
198
- if value:
199
- try:
200
- return expression_to_filter_function(value)
201
- except ValueError:
202
- raise click.UsageError(f"Invalid expression for {arg_name}: {value}") from None
203
- return None