schemathesis 4.0.0a11__py3-none-any.whl → 4.0.0b1__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 (73) hide show
  1. schemathesis/__init__.py +35 -27
  2. schemathesis/auths.py +85 -54
  3. schemathesis/checks.py +65 -36
  4. schemathesis/cli/commands/run/__init__.py +32 -27
  5. schemathesis/cli/commands/run/context.py +6 -1
  6. schemathesis/cli/commands/run/events.py +7 -1
  7. schemathesis/cli/commands/run/executor.py +12 -7
  8. schemathesis/cli/commands/run/handlers/output.py +188 -80
  9. schemathesis/cli/commands/run/validation.py +21 -6
  10. schemathesis/cli/constants.py +1 -1
  11. schemathesis/config/__init__.py +2 -1
  12. schemathesis/config/_generation.py +12 -13
  13. schemathesis/config/_operations.py +14 -0
  14. schemathesis/config/_phases.py +41 -5
  15. schemathesis/config/_projects.py +33 -1
  16. schemathesis/config/_report.py +6 -2
  17. schemathesis/config/_warnings.py +25 -0
  18. schemathesis/config/schema.json +49 -1
  19. schemathesis/core/errors.py +15 -19
  20. schemathesis/core/transport.py +117 -2
  21. schemathesis/engine/context.py +1 -0
  22. schemathesis/engine/errors.py +61 -2
  23. schemathesis/engine/events.py +10 -2
  24. schemathesis/engine/phases/probes.py +3 -0
  25. schemathesis/engine/phases/stateful/__init__.py +2 -1
  26. schemathesis/engine/phases/stateful/_executor.py +38 -5
  27. schemathesis/engine/phases/stateful/context.py +2 -2
  28. schemathesis/engine/phases/unit/_executor.py +36 -7
  29. schemathesis/generation/__init__.py +0 -3
  30. schemathesis/generation/case.py +153 -28
  31. schemathesis/generation/coverage.py +1 -1
  32. schemathesis/generation/hypothesis/builder.py +43 -19
  33. schemathesis/generation/metrics.py +93 -0
  34. schemathesis/generation/modes.py +0 -8
  35. schemathesis/generation/overrides.py +11 -27
  36. schemathesis/generation/stateful/__init__.py +17 -0
  37. schemathesis/generation/stateful/state_machine.py +32 -108
  38. schemathesis/graphql/loaders.py +152 -8
  39. schemathesis/hooks.py +63 -39
  40. schemathesis/openapi/checks.py +82 -20
  41. schemathesis/openapi/generation/filters.py +9 -2
  42. schemathesis/openapi/loaders.py +134 -8
  43. schemathesis/pytest/lazy.py +4 -31
  44. schemathesis/pytest/loaders.py +24 -0
  45. schemathesis/pytest/plugin.py +38 -6
  46. schemathesis/schemas.py +161 -94
  47. schemathesis/specs/graphql/scalars.py +37 -3
  48. schemathesis/specs/graphql/schemas.py +18 -9
  49. schemathesis/specs/openapi/_hypothesis.py +53 -34
  50. schemathesis/specs/openapi/checks.py +111 -47
  51. schemathesis/specs/openapi/expressions/nodes.py +1 -1
  52. schemathesis/specs/openapi/formats.py +30 -3
  53. schemathesis/specs/openapi/media_types.py +44 -1
  54. schemathesis/specs/openapi/negative/__init__.py +5 -3
  55. schemathesis/specs/openapi/negative/mutations.py +2 -2
  56. schemathesis/specs/openapi/parameters.py +0 -3
  57. schemathesis/specs/openapi/schemas.py +14 -93
  58. schemathesis/specs/openapi/stateful/__init__.py +2 -1
  59. schemathesis/specs/openapi/stateful/links.py +1 -63
  60. schemathesis/transport/__init__.py +54 -16
  61. schemathesis/transport/prepare.py +31 -7
  62. schemathesis/transport/requests.py +21 -9
  63. schemathesis/transport/serialization.py +0 -4
  64. schemathesis/transport/wsgi.py +15 -8
  65. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
  66. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
  67. schemathesis/contrib/__init__.py +0 -9
  68. schemathesis/contrib/openapi/__init__.py +0 -9
  69. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  70. schemathesis/generation/targets.py +0 -69
  71. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
  72. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
  73. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass, field
3
+ from dataclasses import dataclass
4
4
  from typing import TYPE_CHECKING, Any, Mapping
5
5
 
6
+ from schemathesis import transport
6
7
  from schemathesis.checks import CHECKS, CheckContext, CheckFunction, run_checks
7
8
  from schemathesis.core import NOT_SET, SCHEMATHESIS_TEST_CASE_HEADER, NotSet, curl
8
9
  from schemathesis.core.failures import FailureGroup, failure_report_title, format_failures
@@ -11,42 +12,121 @@ from schemathesis.generation import generate_random_case_id
11
12
  from schemathesis.generation.meta import CaseMetadata
12
13
  from schemathesis.generation.overrides import Override, store_components
13
14
  from schemathesis.hooks import HookContext, dispatch
14
- from schemathesis.transport.prepare import prepare_request
15
+ from schemathesis.transport.prepare import prepare_path, prepare_request
15
16
 
16
17
  if TYPE_CHECKING:
18
+ import httpx
19
+ import requests
17
20
  import requests.auth
18
21
  from requests.structures import CaseInsensitiveDict
22
+ from werkzeug.test import TestResponse
19
23
 
20
24
  from schemathesis.schemas import APIOperation
21
25
 
22
26
 
27
+ def _default_headers() -> CaseInsensitiveDict:
28
+ from requests.structures import CaseInsensitiveDict
29
+
30
+ return CaseInsensitiveDict()
31
+
32
+
23
33
  @dataclass
24
34
  class Case:
25
- """A single test case parameters."""
35
+ """Generated test case data for a single API operation."""
26
36
 
27
37
  operation: APIOperation
28
38
  method: str
39
+ """HTTP verb (`GET`, `POST`, etc.)"""
29
40
  path: str
30
- # Unique test case identifier
31
- id: str = field(default_factory=generate_random_case_id, compare=False)
32
- path_parameters: dict[str, Any] | None = None
33
- headers: CaseInsensitiveDict | None = None
34
- cookies: dict[str, Any] | None = None
35
- query: dict[str, Any] | None = None
41
+ """Path template from schema (e.g., `/users/{user_id}`)"""
42
+ id: str
43
+ """Random ID sent in headers for log correlation"""
44
+ path_parameters: dict[str, Any]
45
+ """Generated path variables (e.g., `{"user_id": "123"}`)"""
46
+ headers: CaseInsensitiveDict
47
+ """Generated HTTP headers"""
48
+ cookies: dict[str, Any]
49
+ """Generated cookies"""
50
+ query: dict[str, Any]
51
+ """Generated query parameters"""
36
52
  # By default, there is no body, but we can't use `None` as the default value because it clashes with `null`
37
53
  # which is a valid payload.
38
- body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET
39
- # The media type for cases with a payload. For example, "application/json"
40
- media_type: str | None = None
54
+ body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet
55
+ """Generated request body"""
56
+ media_type: str | None
57
+ """Media type from OpenAPI schema (e.g., "multipart/form-data")"""
41
58
 
42
- meta: CaseMetadata | None = field(compare=False, default=None)
59
+ meta: CaseMetadata | None
43
60
 
44
- _auth: requests.auth.AuthBase | None = None
45
- _has_explicit_auth: bool = False
61
+ _auth: requests.auth.AuthBase | None
62
+ _has_explicit_auth: bool
46
63
 
47
- def __post_init__(self) -> None:
64
+ __slots__ = (
65
+ "operation",
66
+ "method",
67
+ "path",
68
+ "id",
69
+ "path_parameters",
70
+ "headers",
71
+ "cookies",
72
+ "query",
73
+ "body",
74
+ "media_type",
75
+ "meta",
76
+ "_auth",
77
+ "_has_explicit_auth",
78
+ "_components",
79
+ )
80
+
81
+ def __init__(
82
+ self,
83
+ operation: APIOperation,
84
+ method: str,
85
+ path: str,
86
+ *,
87
+ id: str | None = None,
88
+ path_parameters: dict[str, Any] | None = None,
89
+ headers: CaseInsensitiveDict | None = None,
90
+ cookies: dict[str, Any] | None = None,
91
+ query: dict[str, Any] | None = None,
92
+ body: list | dict[str, Any] | str | int | float | bool | bytes | "NotSet" = NOT_SET,
93
+ media_type: str | None = None,
94
+ meta: CaseMetadata | None = None,
95
+ _auth: requests.auth.AuthBase | None = None,
96
+ _has_explicit_auth: bool = False,
97
+ ) -> None:
98
+ self.operation = operation
99
+ self.method = method
100
+ self.path = path
101
+
102
+ self.id = id if id is not None else generate_random_case_id()
103
+ self.path_parameters = path_parameters if path_parameters is not None else {}
104
+ self.headers = headers if headers is not None else _default_headers()
105
+ self.cookies = cookies if cookies is not None else {}
106
+ self.query = query if query is not None else {}
107
+ self.body = body
108
+ self.media_type = media_type
109
+ self.meta = meta
110
+ self._auth = _auth
111
+ self._has_explicit_auth = _has_explicit_auth
48
112
  self._components = store_components(self)
49
113
 
114
+ def __eq__(self, other: object) -> bool:
115
+ if not isinstance(other, Case):
116
+ return NotImplemented
117
+
118
+ return (
119
+ self.operation == other.operation
120
+ and self.method == other.method
121
+ and self.path == other.path
122
+ and self.path_parameters == other.path_parameters
123
+ and self.headers == other.headers
124
+ and self.cookies == other.cookies
125
+ and self.query == other.query
126
+ and self.body == other.body
127
+ and self.media_type == other.media_type
128
+ )
129
+
50
130
  @property
51
131
  def _override(self) -> Override:
52
132
  return Override.from_components(self._components, self)
@@ -56,6 +136,8 @@ class Case:
56
136
  first = True
57
137
  for name in ("path_parameters", "headers", "cookies", "query", "body"):
58
138
  value = getattr(self, name)
139
+ if name != "body" and not value:
140
+ continue
59
141
  if value is not None and not isinstance(value, NotSet):
60
142
  if first:
61
143
  first = False
@@ -69,8 +151,19 @@ class Case:
69
151
 
70
152
  def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
71
153
 
154
+ @property
155
+ def formatted_path(self) -> str:
156
+ """Path template with variables substituted (e.g., /users/{user_id} → /users/123)."""
157
+ return prepare_path(self.path, self.path_parameters)
158
+
72
159
  def as_curl_command(self, headers: Mapping[str, Any] | None = None, verify: bool = True) -> str:
73
- """Construct a curl command for a given case."""
160
+ """Generate a curl command that reproduces this test case.
161
+
162
+ Args:
163
+ headers: Additional headers to include in the command.
164
+ verify: When False, adds `--insecure` flag to curl command.
165
+
166
+ """
74
167
  request_data = prepare_request(self, headers, config=self.operation.schema.config.output.sanitization)
75
168
  return curl.generate(
76
169
  method=str(request_data.method),
@@ -82,7 +175,6 @@ class Case:
82
175
  )
83
176
 
84
177
  def as_transport_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
85
- """Convert the test case into a dictionary acceptable by the underlying transport call."""
86
178
  return self.operation.schema.transport.serialize_case(self, base_url=base_url, headers=headers)
87
179
 
88
180
  def call(
@@ -94,11 +186,28 @@ class Case:
94
186
  cookies: dict[str, Any] | None = None,
95
187
  **kwargs: Any,
96
188
  ) -> Response:
189
+ """Make an HTTP request using this test case's data without validation.
190
+
191
+ Use when you need to validate response separately
192
+
193
+ Args:
194
+ base_url: Override the schema's base URL.
195
+ session: Reuse an existing requests session.
196
+ headers: Additional headers.
197
+ params: Additional query parameters.
198
+ cookies: Additional cookies.
199
+ **kwargs: Additional transport-level arguments.
200
+
201
+ """
97
202
  hook_context = HookContext(operation=self.operation)
98
203
  dispatch("before_call", hook_context, self, **kwargs)
99
204
  if self.operation.app is not None:
100
- kwargs["app"] = self.operation.app
101
- response = self.operation.schema.transport.send(
205
+ kwargs.setdefault("app", self.operation.app)
206
+ if "app" in kwargs:
207
+ transport_ = transport.get(kwargs["app"])
208
+ else:
209
+ transport_ = self.operation.schema.transport
210
+ response = transport_.send(
102
211
  self,
103
212
  session=session,
104
213
  base_url=base_url,
@@ -112,26 +221,29 @@ class Case:
112
221
 
113
222
  def validate_response(
114
223
  self,
115
- response: Response,
224
+ response: Response | httpx.Response | requests.Response | TestResponse,
116
225
  checks: list[CheckFunction] | None = None,
117
226
  additional_checks: list[CheckFunction] | None = None,
118
227
  excluded_checks: list[CheckFunction] | None = None,
119
228
  headers: dict[str, Any] | None = None,
120
229
  transport_kwargs: dict[str, Any] | None = None,
121
230
  ) -> None:
122
- """Validate application response.
231
+ """Validate a response against the API schema and built-in checks.
123
232
 
124
- By default, all available checks will be applied.
233
+ Args:
234
+ response: Response to validate.
235
+ checks: Explicit set of checks to run.
236
+ additional_checks: Additional custom checks to run.
237
+ excluded_checks: Built-in checks to skip.
238
+ headers: Headers used in the original request.
239
+ transport_kwargs: Transport arguments used in the original request.
125
240
 
126
- :param response: Application response.
127
- :param checks: A tuple of check functions that accept ``response`` and ``case``.
128
- :param additional_checks: A tuple of additional checks that will be executed after ones from the ``checks``
129
- argument.
130
- :param excluded_checks: Checks excluded from the default ones.
131
241
  """
132
242
  __tracebackhide__ = True
133
243
  from requests.structures import CaseInsensitiveDict
134
244
 
245
+ response = Response.from_any(response)
246
+
135
247
  checks = [
136
248
  check
137
249
  for check in list(checks or CHECKS.get_all()) + list(additional_checks or [])
@@ -167,6 +279,7 @@ class Case:
167
279
  curl=curl,
168
280
  config=self.operation.schema.config.output,
169
281
  )
282
+ message += "\n\n"
170
283
  raise FailureGroup(_failures, message) from None
171
284
 
172
285
  def call_and_validate(
@@ -179,6 +292,18 @@ class Case:
179
292
  excluded_checks: list[CheckFunction] | None = None,
180
293
  **kwargs: Any,
181
294
  ) -> Response:
295
+ """Make an HTTP request and validates the response automatically.
296
+
297
+ Args:
298
+ base_url: Override the schema's base URL.
299
+ session: Reuse an existing requests session.
300
+ headers: Additional headers to send.
301
+ checks: Explicit set of checks to run.
302
+ additional_checks: Additional custom checks to run.
303
+ excluded_checks: Built-in checks to skip.
304
+ **kwargs: Additional transport-level arguments.
305
+
306
+ """
182
307
  __tracebackhide__ = True
183
308
  response = self.call(base_url, session, headers, **kwargs)
184
309
  self.validate_response(
@@ -121,7 +121,7 @@ class CoverageContext:
121
121
  path: list[str | int] | None = None,
122
122
  ) -> None:
123
123
  self.location = location
124
- self.generation_modes = generation_modes if generation_modes is not None else GenerationMode.all()
124
+ self.generation_modes = generation_modes if generation_modes is not None else list(GenerationMode)
125
125
  self.path = path or []
126
126
 
127
127
  @contextmanager
@@ -9,7 +9,7 @@ from time import perf_counter
9
9
  from typing import Any, Callable, Generator, Mapping
10
10
 
11
11
  import hypothesis
12
- from hypothesis import Phase
12
+ from hypothesis import Phase, Verbosity
13
13
  from hypothesis import strategies as st
14
14
  from hypothesis._settings import all_settings
15
15
  from hypothesis.errors import Unsatisfiable
@@ -27,6 +27,7 @@ from schemathesis.core.validation import has_invalid_characters, is_latin_1_enco
27
27
  from schemathesis.generation import GenerationMode, coverage
28
28
  from schemathesis.generation.case import Case
29
29
  from schemathesis.generation.hypothesis import DEFAULT_DEADLINE, examples, setup, strategies
30
+ from schemathesis.generation.hypothesis.examples import add_single_example
30
31
  from schemathesis.generation.hypothesis.given import GivenInput
31
32
  from schemathesis.generation.meta import (
32
33
  CaseMetadata,
@@ -96,14 +97,17 @@ def create_test(
96
97
  if settings.deadline == default.deadline:
97
98
  settings = hypothesis.settings(settings, deadline=DEFAULT_DEADLINE)
98
99
 
100
+ if settings.verbosity == default.verbosity:
101
+ settings = hypothesis.settings(settings, verbosity=Verbosity.quiet)
102
+
99
103
  if config.settings is not None:
100
104
  # Merge the user-provided settings with the current ones
101
105
  settings = hypothesis.settings(
102
- settings,
106
+ config.settings,
103
107
  **{
104
- item: getattr(config.settings, item)
108
+ item: getattr(settings, item)
105
109
  for item in all_settings
106
- if getattr(config.settings, item) != getattr(default, item)
110
+ if getattr(settings, item) != getattr(default, item)
107
111
  },
108
112
  )
109
113
 
@@ -126,7 +130,14 @@ def create_test(
126
130
  and Phase.explicit in settings.phases
127
131
  and specification.supports_feature(SpecificationFeature.EXAMPLES)
128
132
  ):
129
- hypothesis_test = add_examples(hypothesis_test, operation, hook_dispatcher=hook_dispatcher, **strategy_kwargs)
133
+ phases_config = config.project.phases_for(operation=operation)
134
+ hypothesis_test = add_examples(
135
+ hypothesis_test,
136
+ operation,
137
+ fill_missing=phases_config.examples.fill_missing,
138
+ hook_dispatcher=hook_dispatcher,
139
+ **strategy_kwargs,
140
+ )
130
141
 
131
142
  if (
132
143
  HypothesisTestMode.COVERAGE in config.modes
@@ -142,7 +153,8 @@ def create_test(
142
153
  generation.modes,
143
154
  auth_storage,
144
155
  config.as_strategy_kwargs,
145
- phases_config.coverage.unexpected_methods,
156
+ generate_duplicate_query_parameters=phases_config.coverage.generate_duplicate_query_parameters,
157
+ unexpected_methods=phases_config.coverage.unexpected_methods,
146
158
  )
147
159
 
148
160
  setattr(hypothesis_test, SETTINGS_ATTRIBUTE_NAME, settings)
@@ -188,7 +200,11 @@ def make_async_test(test: Callable) -> Callable:
188
200
 
189
201
 
190
202
  def add_examples(
191
- test: Callable, operation: APIOperation, hook_dispatcher: HookDispatcher | None = None, **kwargs: Any
203
+ test: Callable,
204
+ operation: APIOperation,
205
+ fill_missing: bool,
206
+ hook_dispatcher: HookDispatcher | None = None,
207
+ **kwargs: Any,
192
208
  ) -> Callable:
193
209
  """Add examples to the Hypothesis test, if they are specified in the schema."""
194
210
  from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
@@ -212,7 +228,11 @@ def add_examples(
212
228
  if isinstance(exc, SchemaError):
213
229
  InvalidRegexMark.set(test, exc)
214
230
 
215
- context = HookContext(operation) # context should be passed here instead
231
+ if fill_missing and not result:
232
+ strategy = operation.as_strategy()
233
+ add_single_example(strategy, result)
234
+
235
+ context = HookContext(operation=operation) # context should be passed here instead
216
236
  GLOBAL_HOOK_DISPATCHER.dispatch("before_add_examples", context, result)
217
237
  operation.schema.hooks.dispatch("before_add_examples", context, result)
218
238
  if hook_dispatcher:
@@ -246,6 +266,7 @@ def add_coverage(
246
266
  generation_modes: list[GenerationMode],
247
267
  auth_storage: AuthStorage | None,
248
268
  as_strategy_kwargs: dict[str, Any],
269
+ generate_duplicate_query_parameters: bool,
249
270
  unexpected_methods: set[str] | None = None,
250
271
  ) -> Callable:
251
272
  from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
@@ -259,7 +280,9 @@ def add_coverage(
259
280
  for container in LOCATION_TO_CONTAINER.values()
260
281
  if container in as_strategy_kwargs
261
282
  }
262
- for case in _iter_coverage_cases(operation, generation_modes, unexpected_methods):
283
+ for case in _iter_coverage_cases(
284
+ operation, generation_modes, generate_duplicate_query_parameters, unexpected_methods
285
+ ):
263
286
  if case.media_type and operation.schema.transport.get_first_matching_media_type(case.media_type) is None:
264
287
  continue
265
288
  adjust_urlencoded_payload(case)
@@ -396,6 +419,7 @@ def _stringify_value(val: Any, container_name: str) -> Any:
396
419
  def _iter_coverage_cases(
397
420
  operation: APIOperation,
398
421
  generation_modes: list[GenerationMode],
422
+ generate_duplicate_query_parameters: bool,
399
423
  unexpected_methods: set[str] | None = None,
400
424
  ) -> Generator[Case, None, None]:
401
425
  from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
@@ -451,7 +475,7 @@ def _iter_coverage_cases(
451
475
  data = template.with_body(value=value, media_type=body.media_type)
452
476
  yield operation.Case(
453
477
  **data.kwargs,
454
- meta=CaseMetadata(
478
+ _meta=CaseMetadata(
455
479
  generation=GenerationInfo(
456
480
  time=elapsed,
457
481
  mode=value.generation_mode,
@@ -473,7 +497,7 @@ def _iter_coverage_cases(
473
497
  data = template.with_body(value=next_value, media_type=body.media_type)
474
498
  yield operation.Case(
475
499
  **data.kwargs,
476
- meta=CaseMetadata(
500
+ _meta=CaseMetadata(
477
501
  generation=GenerationInfo(
478
502
  time=instant.elapsed,
479
503
  mode=next_value.generation_mode,
@@ -494,7 +518,7 @@ def _iter_coverage_cases(
494
518
  seen_positive.insert(data.kwargs)
495
519
  yield operation.Case(
496
520
  **data.kwargs,
497
- meta=CaseMetadata(
521
+ _meta=CaseMetadata(
498
522
  generation=GenerationInfo(
499
523
  time=template_time,
500
524
  mode=GenerationMode.POSITIVE,
@@ -522,7 +546,7 @@ def _iter_coverage_cases(
522
546
 
523
547
  yield operation.Case(
524
548
  **data.kwargs,
525
- meta=CaseMetadata(
549
+ _meta=CaseMetadata(
526
550
  generation=GenerationInfo(time=instant.elapsed, mode=value.generation_mode),
527
551
  components=data.components,
528
552
  phase=PhaseInfo.coverage(
@@ -542,14 +566,14 @@ def _iter_coverage_cases(
542
566
  yield operation.Case(
543
567
  **data.kwargs,
544
568
  method=method.upper(),
545
- meta=CaseMetadata(
569
+ _meta=CaseMetadata(
546
570
  generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
547
571
  components=data.components,
548
572
  phase=PhaseInfo.coverage(description=f"Unspecified HTTP method: {method.upper()}"),
549
573
  ),
550
574
  )
551
575
  # Generate duplicate query parameters
552
- if operation.query:
576
+ if generate_duplicate_query_parameters and operation.query:
553
577
  container = template["query"]
554
578
  for parameter in operation.query:
555
579
  instant = Instant()
@@ -564,7 +588,7 @@ def _iter_coverage_cases(
564
588
  )
565
589
  yield operation.Case(
566
590
  **data.kwargs,
567
- meta=CaseMetadata(
591
+ _meta=CaseMetadata(
568
592
  generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
569
593
  components=data.components,
570
594
  phase=PhaseInfo.coverage(
@@ -589,7 +613,7 @@ def _iter_coverage_cases(
589
613
  )
590
614
  yield operation.Case(
591
615
  **data.kwargs,
592
- meta=CaseMetadata(
616
+ _meta=CaseMetadata(
593
617
  generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
594
618
  components=data.components,
595
619
  phase=PhaseInfo.coverage(
@@ -631,7 +655,7 @@ def _iter_coverage_cases(
631
655
  )
632
656
  return operation.Case(
633
657
  **data.kwargs,
634
- meta=CaseMetadata(
658
+ _meta=CaseMetadata(
635
659
  generation=GenerationInfo(
636
660
  time=_instant.elapsed,
637
661
  mode=_generation_mode,
@@ -757,7 +781,7 @@ def _case_to_kwargs(case: Case) -> dict:
757
781
  kwargs = {}
758
782
  for container_name in LOCATION_TO_CONTAINER.values():
759
783
  value = getattr(case, container_name)
760
- if isinstance(value, CaseInsensitiveDict):
784
+ if isinstance(value, CaseInsensitiveDict) and value:
761
785
  kwargs[container_name] = dict(value)
762
786
  elif value and value is not NOT_SET:
763
787
  kwargs[container_name] = value
@@ -0,0 +1,93 @@
1
+ """Support for Targeted Property-Based Testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, Sequence
7
+
8
+ from schemathesis.core.registries import Registry
9
+ from schemathesis.core.transport import Response
10
+ from schemathesis.generation.case import Case
11
+
12
+
13
+ @dataclass
14
+ class MetricContext:
15
+ """Context for evaluating a metric on a single test execution.
16
+
17
+ This object bundles together the test `case` that was sent and
18
+ the corresponding HTTP `response`. Metric functions receive an
19
+ instance of `MetricContext` to compute a numeric score.
20
+ """
21
+
22
+ case: Case
23
+ """Generated test case."""
24
+ response: Response
25
+ """The HTTP response returned by the server for this test case."""
26
+
27
+ __slots__ = ("case", "response")
28
+
29
+
30
+ MetricFunction = Callable[[MetricContext], float]
31
+
32
+ METRICS = Registry[MetricFunction]()
33
+
34
+
35
+ def metric(func: MetricFunction) -> MetricFunction:
36
+ """Decorator to register a custom metric for targeted property-based testing.
37
+
38
+ Example:
39
+ ```python
40
+ import schemathesis
41
+
42
+ @schemathesis.metric
43
+ def response_size(ctx: schemathesis.MetricContext) -> float:
44
+ return float(len(ctx.response.content))
45
+ ```
46
+
47
+ """
48
+ return METRICS.register(func)
49
+
50
+
51
+ @metric
52
+ def response_time(ctx: MetricContext) -> float:
53
+ """Response time as a metric to maximize."""
54
+ return ctx.response.elapsed
55
+
56
+
57
+ class MetricCollector:
58
+ """Collect multiple observations for metrics."""
59
+
60
+ __slots__ = ("metrics", "observations")
61
+
62
+ def __init__(self, metrics: list[MetricFunction] | None = None) -> None:
63
+ self.metrics = metrics or []
64
+ self.observations: dict[str, list[float]] = {metric.__name__: [] for metric in self.metrics}
65
+
66
+ def reset(self) -> None:
67
+ """Reset all collected observations."""
68
+ for metric in self.metrics:
69
+ self.observations[metric.__name__].clear()
70
+
71
+ def store(self, case: Case, response: Response) -> None:
72
+ """Calculate metrics & store them."""
73
+ ctx = MetricContext(case=case, response=response)
74
+ for metric in self.metrics:
75
+ self.observations[metric.__name__].append(metric(ctx))
76
+
77
+ def maximize(self) -> None:
78
+ """Give feedback to the Hypothesis engine, so it maximizes the aggregated metrics."""
79
+ import hypothesis
80
+
81
+ for metric in self.metrics:
82
+ # Currently aggregation is just a sum
83
+ value = sum(self.observations[metric.__name__])
84
+ hypothesis.target(value, label=metric.__name__)
85
+
86
+
87
+ def maximize(metrics: Sequence[MetricFunction], case: Case, response: Response) -> None:
88
+ import hypothesis
89
+
90
+ ctx = MetricContext(case=case, response=response)
91
+ for metric in metrics:
92
+ value = metric(ctx)
93
+ hypothesis.target(value, label=metric.__name__)
@@ -11,14 +11,6 @@ class GenerationMode(str, Enum):
11
11
  # Doesn't fit the API schema
12
12
  NEGATIVE = "negative"
13
13
 
14
- @classmethod
15
- def default(cls) -> GenerationMode:
16
- return cls.POSITIVE
17
-
18
- @classmethod
19
- def all(cls) -> list[GenerationMode]:
20
- return list(GenerationMode)
21
-
22
14
  @property
23
15
  def is_positive(self) -> bool:
24
16
  return self == GenerationMode.POSITIVE
@@ -2,17 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  from collections.abc import Mapping
4
4
  from dataclasses import dataclass
5
- from typing import TYPE_CHECKING, Any, Callable
5
+ from typing import TYPE_CHECKING, Any, Iterator
6
6
 
7
7
  from schemathesis.config import ProjectConfig
8
- from schemathesis.core.errors import IncorrectUsage
9
- from schemathesis.core.marks import Mark
10
8
  from schemathesis.core.transforms import diff
11
9
  from schemathesis.generation.meta import ComponentKind
12
10
 
13
11
  if TYPE_CHECKING:
14
12
  from schemathesis.generation.case import Case
15
- from schemathesis.schemas import APIOperation, Parameter, ParameterSet
13
+ from schemathesis.schemas import APIOperation, Parameter
16
14
 
17
15
 
18
16
  @dataclass
@@ -24,13 +22,15 @@ class Override:
24
22
  cookies: dict[str, str]
25
23
  path_parameters: dict[str, str]
26
24
 
27
- def for_operation(self, operation: APIOperation) -> dict[str, dict[str, str]]:
28
- return {
29
- "query": (_for_parameters(self.query, operation.query)),
30
- "headers": (_for_parameters(self.headers, operation.headers)),
31
- "cookies": (_for_parameters(self.cookies, operation.cookies)),
32
- "path_parameters": (_for_parameters(self.path_parameters, operation.path_parameters)),
33
- }
25
+ def items(self) -> Iterator[tuple[str, dict[str, str]]]:
26
+ for key, value in (
27
+ ("query", self.query),
28
+ ("headers", self.headers),
29
+ ("cookies", self.cookies),
30
+ ("path_parameters", self.path_parameters),
31
+ ):
32
+ if value:
33
+ yield key, value
34
34
 
35
35
  @classmethod
36
36
  def from_components(cls, components: dict[ComponentKind, StoredValue], case: Case) -> Override:
@@ -77,14 +77,6 @@ def _get_override_value(param: Parameter, parameters: dict[str, Any]) -> Any:
77
77
  return None
78
78
 
79
79
 
80
- def _for_parameters(overridden: dict[str, str], defined: ParameterSet) -> dict[str, str]:
81
- output = {}
82
- for param in defined:
83
- if param.name in overridden:
84
- output[param.name] = overridden[param.name]
85
- return output
86
-
87
-
88
80
  @dataclass
89
81
  class StoredValue:
90
82
  value: dict[str, Any] | None
@@ -122,11 +114,3 @@ def store_components(case: Case) -> dict[ComponentKind, StoredValue]:
122
114
  ComponentKind.PATH_PARAMETERS,
123
115
  ]
124
116
  }
125
-
126
-
127
- OverrideMark = Mark[Override](attr_name="override")
128
-
129
-
130
- def check_no_override_mark(test: Callable) -> None:
131
- if OverrideMark.is_set(test):
132
- raise IncorrectUsage(f"`{test.__name__}` has already been decorated with `override`.")