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
@@ -108,7 +108,7 @@ class SchemathesisCase(PyCollector):
108
108
  This implementation is based on the original one in pytest, but with slight adjustments
109
109
  to produce tests out of hypothesis ones.
110
110
  """
111
- from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, create_test
111
+ from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, HypothesisTestMode, create_test
112
112
 
113
113
  is_trio_test = False
114
114
  for mark in getattr(self.test_function, "pytestmark", []):
@@ -133,6 +133,7 @@ class SchemathesisCase(PyCollector):
133
133
  operation=operation,
134
134
  test_func=self.test_function,
135
135
  config=HypothesisTestConfig(
136
+ modes=list(HypothesisTestMode),
136
137
  given_kwargs=self.given_kwargs,
137
138
  generation=self.schema.generation_config,
138
139
  as_strategy_kwargs=as_strategy_kwargs,
@@ -149,11 +150,10 @@ class SchemathesisCase(PyCollector):
149
150
  error = result.err()
150
151
  funcobj = error.as_failing_test_function()
151
152
  name = self.name
152
- # `full_path` is always available in this case
153
153
  if error.method:
154
- name += f"[{error.method.upper()} {error.full_path}]"
154
+ name += f"[{error.method.upper()} {error.path}]"
155
155
  else:
156
- name += f"[{error.full_path}]"
156
+ name += f"[{error.path}]"
157
157
 
158
158
  cls = self._get_class_parent()
159
159
  definition: FunctionDefinition = FunctionDefinition.from_parent(
schemathesis/schemas.py CHANGED
@@ -619,7 +619,7 @@ class APIOperation(Generic[P]):
619
619
 
620
620
  def __post_init__(self) -> None:
621
621
  if self.label is None:
622
- self.label = f"{self.method.upper()} {self.full_path}" # type: ignore
622
+ self.label = f"{self.method.upper()} {self.path}" # type: ignore
623
623
 
624
624
  @property
625
625
  def full_path(self) -> str:
@@ -41,11 +41,20 @@ if TYPE_CHECKING:
41
41
  from ...schemas import APIOperation
42
42
 
43
43
 
44
+ def is_unexpected_http_status_case(case: Case) -> bool:
45
+ # Skip checks for requests using HTTP methods not defined in the API spec
46
+ return bool(
47
+ case.meta
48
+ and isinstance(case.meta.phase.data, CoveragePhaseData)
49
+ and case.meta.phase.data.description.startswith("Unspecified HTTP method")
50
+ )
51
+
52
+
44
53
  @schemathesis.check
45
54
  def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
46
55
  from .schemas import BaseOpenAPISchema
47
56
 
48
- if not isinstance(case.operation.schema, BaseOpenAPISchema):
57
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
49
58
  return True
50
59
  responses = case.operation.definition.raw.get("responses", {})
51
60
  # "default" can be used as the default response object for all HTTP codes that are not covered individually
@@ -74,7 +83,7 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
74
83
  def content_type_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
75
84
  from .schemas import BaseOpenAPISchema
76
85
 
77
- if not isinstance(case.operation.schema, BaseOpenAPISchema):
86
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
78
87
  return True
79
88
  documented_content_types = case.operation.schema.get_content_types(case.operation, response)
80
89
  if not documented_content_types:
@@ -128,7 +137,7 @@ def response_headers_conformance(ctx: CheckContext, response: Response, case: Ca
128
137
  from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
129
138
  from .schemas import BaseOpenAPISchema, OpenApi30, _maybe_raise_one_or_more
130
139
 
131
- if not isinstance(case.operation.schema, BaseOpenAPISchema):
140
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
132
141
  return True
133
142
  resolved = case.operation.schema.get_headers(case.operation, response)
134
143
  if not resolved:
@@ -208,7 +217,7 @@ def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | floa
208
217
  def response_schema_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
209
218
  from .schemas import BaseOpenAPISchema
210
219
 
211
- if not isinstance(case.operation.schema, BaseOpenAPISchema):
220
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
212
221
  return True
213
222
  return case.operation.validate_response(response)
214
223
 
@@ -217,7 +226,11 @@ def response_schema_conformance(ctx: CheckContext, response: Response, case: Cas
217
226
  def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -> bool | None:
218
227
  from .schemas import BaseOpenAPISchema
219
228
 
220
- if not isinstance(case.operation.schema, BaseOpenAPISchema) or case.meta is None:
229
+ if (
230
+ not isinstance(case.operation.schema, BaseOpenAPISchema)
231
+ or case.meta is None
232
+ or is_unexpected_http_status_case(case)
233
+ ):
221
234
  return True
222
235
 
223
236
  config = ctx.config.get(negative_data_rejection, NegativeDataRejectionConfig())
@@ -241,7 +254,11 @@ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -
241
254
  def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
242
255
  from .schemas import BaseOpenAPISchema
243
256
 
244
- if not isinstance(case.operation.schema, BaseOpenAPISchema) or case.meta is None:
257
+ if (
258
+ not isinstance(case.operation.schema, BaseOpenAPISchema)
259
+ or case.meta is None
260
+ or is_unexpected_http_status_case(case)
261
+ ):
245
262
  return True
246
263
 
247
264
  config = ctx.config.get(positive_data_acceptance, PositiveDataAcceptanceConfig())
@@ -260,7 +277,7 @@ def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case)
260
277
  def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
261
278
  # NOTE: This check is intentionally not registered with `@schemathesis.check` because it is experimental
262
279
  meta = case.meta
263
- if meta is None or not isinstance(meta.phase.data, CoveragePhaseData):
280
+ if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or is_unexpected_http_status_case(case):
264
281
  return None
265
282
  data = meta.phase.data
266
283
  if (
@@ -282,7 +299,7 @@ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -
282
299
 
283
300
  def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
284
301
  meta = case.meta
285
- if meta is None or not isinstance(meta.phase.data, CoveragePhaseData):
302
+ if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or response.request.method == "OPTIONS":
286
303
  return None
287
304
  data = meta.phase.data
288
305
  if data.description and data.description.startswith("Unspecified HTTP method:"):
@@ -332,7 +349,7 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
332
349
  def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool | None:
333
350
  from .schemas import BaseOpenAPISchema
334
351
 
335
- if not isinstance(case.operation.schema, BaseOpenAPISchema):
352
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
336
353
  return True
337
354
  if response.status_code == 404 or response.status_code >= 500:
338
355
  return None
@@ -373,7 +390,7 @@ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool |
373
390
  def ensure_resource_availability(ctx: CheckContext, response: Response, case: Case) -> bool | None:
374
391
  from .schemas import BaseOpenAPISchema
375
392
 
376
- if not isinstance(case.operation.schema, BaseOpenAPISchema):
393
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
377
394
  return True
378
395
 
379
396
  # First, check if this is a 4XX response
@@ -453,7 +470,7 @@ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | No
453
470
  """Check if an operation declares authentication as a requirement but does not actually enforce it."""
454
471
  from .schemas import BaseOpenAPISchema
455
472
 
456
- if not isinstance(case.operation.schema, BaseOpenAPISchema):
473
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
457
474
  return True
458
475
  security_parameters = _get_security_parameters(case.operation)
459
476
  # Authentication is required for this API operation and response is successful
@@ -16,6 +16,7 @@ from schemathesis.generation.case import Case
16
16
  from schemathesis.generation.hypothesis import examples
17
17
  from schemathesis.generation.meta import TestPhase
18
18
  from schemathesis.schemas import APIOperation
19
+ from schemathesis.specs.openapi.serialization import get_serializers_for_operation
19
20
 
20
21
  from ._hypothesis import get_default_format_strategies, openapi_cases
21
22
  from .constants import LOCATION_TO_CONTAINER
@@ -50,11 +51,7 @@ def get_strategies_from_examples(
50
51
  operation: APIOperation[OpenAPIParameter], **kwargs: Any
51
52
  ) -> list[SearchStrategy[Case]]:
52
53
  """Build a set of strategies that generate test cases based on explicit examples in the schema."""
53
- maps = {}
54
- for location, container in LOCATION_TO_CONTAINER.items():
55
- serializer = operation.get_parameter_serializer(location)
56
- if serializer is not None:
57
- maps[container] = serializer
54
+ maps = get_serializers_for_operation(operation)
58
55
 
59
56
  def serialize_components(case: Case) -> Case:
60
57
  """Applies special serialization rules for case components.
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  import json
9
9
  from typing import Any
10
10
 
11
+ from schemathesis.core.transforms import UNRESOLVABLE, Unresolvable
11
12
  from schemathesis.generation.stateful.state_machine import StepOutput
12
13
 
13
14
  from . import lexer, nodes, parser
@@ -25,21 +26,36 @@ def evaluate(expr: Any, output: StepOutput, evaluate_nested: bool = False) -> An
25
26
  parts = [node.evaluate(output) for node in parser.parse(expr)]
26
27
  if len(parts) == 1:
27
28
  return parts[0] # keep the return type the same as the internal value type
28
- # otherwise, concatenate into a string
29
+ if any(isinstance(part, Unresolvable) for part in parts):
30
+ return UNRESOLVABLE
29
31
  return "".join(str(part) for part in parts if part is not None)
30
32
 
31
33
 
32
34
  def _evaluate_nested(expr: dict[str, Any] | list, output: StepOutput) -> Any:
33
35
  if isinstance(expr, dict):
34
- return {
35
- _evaluate_object_key(key, output): evaluate(value, output, evaluate_nested=True)
36
- for key, value in expr.items()
37
- }
38
- return [evaluate(item, output, evaluate_nested=True) for item in expr]
36
+ result_dict = {}
37
+ for key, value in expr.items():
38
+ new_key = _evaluate_object_key(key, output)
39
+ if new_key is UNRESOLVABLE:
40
+ return new_key
41
+ new_value = evaluate(value, output, evaluate_nested=True)
42
+ if new_value is UNRESOLVABLE:
43
+ return new_value
44
+ result_dict[new_key] = new_value
45
+ return result_dict
46
+ result_list = []
47
+ for item in expr:
48
+ new_value = evaluate(item, output, evaluate_nested=True)
49
+ if new_value is UNRESOLVABLE:
50
+ return new_value
51
+ result_list.append(new_value)
52
+ return result_list
39
53
 
40
54
 
41
55
  def _evaluate_object_key(key: str, output: StepOutput) -> Any:
42
56
  evaluated = evaluate(key, output)
57
+ if evaluated is UNRESOLVABLE:
58
+ return evaluated
43
59
  if isinstance(evaluated, str):
44
60
  return evaluated
45
61
  if isinstance(evaluated, bool):
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, cast
8
8
 
9
9
  from requests.structures import CaseInsensitiveDict
10
10
 
11
- from schemathesis.core.transforms import UNRESOLVABLE, resolve_pointer
11
+ from schemathesis.core.transforms import UNRESOLVABLE, Unresolvable, resolve_pointer
12
12
  from schemathesis.generation.stateful.state_machine import StepOutput
13
13
  from schemathesis.transport.requests import REQUESTS_TRANSPORT
14
14
 
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
20
20
  class Node:
21
21
  """Generic expression node."""
22
22
 
23
- def evaluate(self, output: StepOutput) -> str:
23
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
24
24
  raise NotImplementedError
25
25
 
26
26
 
@@ -39,7 +39,7 @@ class String(Node):
39
39
 
40
40
  value: str
41
41
 
42
- def evaluate(self, output: StepOutput) -> str:
42
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
43
43
  """String tokens are passed as they are.
44
44
 
45
45
  ``foo{$request.path.id}``
@@ -53,7 +53,7 @@ class String(Node):
53
53
  class URL(Node):
54
54
  """A node for `$url` expression."""
55
55
 
56
- def evaluate(self, output: StepOutput) -> str:
56
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
57
57
  import requests
58
58
 
59
59
  base_url = output.case.operation.base_url or "http://127.0.0.1"
@@ -66,7 +66,7 @@ class URL(Node):
66
66
  class Method(Node):
67
67
  """A node for `$method` expression."""
68
68
 
69
- def evaluate(self, output: StepOutput) -> str:
69
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
70
70
  return output.case.operation.method.upper()
71
71
 
72
72
 
@@ -74,7 +74,7 @@ class Method(Node):
74
74
  class StatusCode(Node):
75
75
  """A node for `$statusCode` expression."""
76
76
 
77
- def evaluate(self, output: StepOutput) -> str:
77
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
78
78
  return str(output.response.status_code)
79
79
 
80
80
 
@@ -86,7 +86,7 @@ class NonBodyRequest(Node):
86
86
  parameter: str
87
87
  extractor: Extractor | None = None
88
88
 
89
- def evaluate(self, output: StepOutput) -> str:
89
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
90
90
  container: dict | CaseInsensitiveDict = {
91
91
  "query": output.case.query,
92
92
  "path": output.case.path_parameters,
@@ -96,9 +96,9 @@ class NonBodyRequest(Node):
96
96
  container = CaseInsensitiveDict(container)
97
97
  value = container.get(self.parameter)
98
98
  if value is None:
99
- return ""
99
+ return UNRESOLVABLE
100
100
  if self.extractor is not None:
101
- return self.extractor.extract(value) or ""
101
+ return self.extractor.extract(value) or UNRESOLVABLE
102
102
  return value
103
103
 
104
104
 
@@ -108,14 +108,11 @@ class BodyRequest(Node):
108
108
 
109
109
  pointer: str | None = None
110
110
 
111
- def evaluate(self, output: StepOutput) -> Any:
111
+ def evaluate(self, output: StepOutput) -> Any | Unresolvable:
112
112
  document = output.case.body
113
113
  if self.pointer is None:
114
114
  return document
115
- resolved = resolve_pointer(document, self.pointer[1:])
116
- if resolved is UNRESOLVABLE:
117
- return None
118
- return resolved
115
+ return resolve_pointer(document, self.pointer[1:])
119
116
 
120
117
 
121
118
  @dataclass
@@ -125,12 +122,12 @@ class HeaderResponse(Node):
125
122
  parameter: str
126
123
  extractor: Extractor | None = None
127
124
 
128
- def evaluate(self, output: StepOutput) -> str:
125
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
129
126
  value = output.response.headers.get(self.parameter.lower())
130
127
  if value is None:
131
- return ""
128
+ return UNRESOLVABLE
132
129
  if self.extractor is not None:
133
- return self.extractor.extract(value[0]) or ""
130
+ return self.extractor.extract(value[0]) or UNRESOLVABLE
134
131
  return value[0]
135
132
 
136
133
 
@@ -145,7 +142,4 @@ class BodyResponse(Node):
145
142
  if self.pointer is None:
146
143
  # We need the parsed document - data will be serialized before sending to the application
147
144
  return document
148
- resolved = resolve_pointer(document, self.pointer[1:])
149
- if resolved is UNRESOLVABLE:
150
- return None
151
- return resolved
145
+ return resolve_pointer(document, self.pointer[1:])
@@ -46,7 +46,7 @@ def _parse_variable(tokens: lexer.TokenGenerator, token: lexer.Token, expr: str)
46
46
  elif token.value == nodes.NodeType.RESPONSE.value:
47
47
  yield _parse_response(tokens, expr)
48
48
  else:
49
- raise UnknownToken(token.value)
49
+ raise UnknownToken(f"Invalid expression `{expr}`. Unknown token: `{token.value}`")
50
50
 
51
51
 
52
52
  def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> nodes.BodyRequest | nodes.NonBodyRequest:
@@ -376,7 +376,6 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
376
376
  ),
377
377
  path=operation.path,
378
378
  method=operation.method,
379
- full_path=operation.full_path,
380
379
  )
381
380
  return data["schema"]
382
381
  # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#fixed-fields-10
@@ -388,7 +387,6 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
388
387
  MISSING_SCHEMA_OR_CONTENT_MESSAGE.format(location=data.get("in", ""), name=data.get("name", "<UNKNOWN>")),
389
388
  path=operation.path,
390
389
  method=operation.method,
391
- full_path=operation.full_path,
392
390
  ) from exc
393
391
  options = iter(content.values())
394
392
  media_type_object = next(options)
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import re
4
4
  from functools import lru_cache
5
5
 
6
+ from schemathesis.core.errors import InternalError
7
+
6
8
  try: # pragma: no cover
7
9
  import re._constants as sre
8
10
  import re._parser as sre_parse
@@ -29,7 +31,15 @@ def update_quantifier(pattern: str, min_length: int | None, max_length: int | No
29
31
 
30
32
  try:
31
33
  parsed = sre_parse.parse(pattern)
32
- return _handle_parsed_pattern(parsed, pattern, min_length, max_length)
34
+ updated = _handle_parsed_pattern(parsed, pattern, min_length, max_length)
35
+ try:
36
+ re.compile(updated)
37
+ except re.error as exc:
38
+ raise InternalError(
39
+ f"The combination of min_length={min_length} and max_length={max_length} applied to the original pattern '{pattern}' resulted in an invalid regex: '{updated}'. "
40
+ "This indicates a bug in the regex quantifier merging logic"
41
+ ) from exc
42
+ return updated
33
43
  except re.error:
34
44
  # Invalid pattern
35
45
  return pattern
@@ -261,13 +271,18 @@ def _handle_repeat_quantifier(
261
271
  min_length, max_length = _build_size(min_repeat, max_repeat, min_length, max_length)
262
272
  if min_length > max_length:
263
273
  return pattern
264
- return f"({_strip_quantifier(pattern).strip(')(')})" + _build_quantifier(min_length, max_length)
274
+ inner = _strip_quantifier(pattern)
275
+ if inner.startswith("(") and inner.endswith(")"):
276
+ inner = inner[1:-1]
277
+ return f"({inner})" + _build_quantifier(min_length, max_length)
265
278
 
266
279
 
267
280
  def _handle_literal_or_in_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
268
281
  """Handle literal or character class quantifiers."""
269
282
  min_length = 1 if min_length is None else max(min_length, 1)
270
- return f"({pattern.strip(')(')})" + _build_quantifier(min_length, max_length)
283
+ if pattern.startswith("(") and pattern.endswith(")"):
284
+ pattern = pattern[1:-1]
285
+ return f"({pattern})" + _build_quantifier(min_length, max_length)
271
286
 
272
287
 
273
288
  def _build_quantifier(minimum: int | None, maximum: int | None) -> str:
@@ -294,10 +309,12 @@ def _build_size(min_repeat: int, max_repeat: int, min_length: int | None, max_le
294
309
  def _strip_quantifier(pattern: str) -> str:
295
310
  """Remove quantifier from the pattern."""
296
311
  # Lazy & posessive quantifiers
297
- if pattern.endswith(("*?", "+?", "??", "*+", "?+", "++")):
298
- return pattern[:-2]
299
- if pattern.endswith(("?", "*", "+")):
300
- pattern = pattern[:-1]
312
+ for marker in ("*?", "+?", "??", "*+", "?+", "++"):
313
+ if pattern.endswith(marker) and not pattern.endswith(rf"\{marker}"):
314
+ return pattern[:-2]
315
+ for marker in ("?", "*", "+"):
316
+ if pattern.endswith(marker) and not pattern.endswith(rf"\{marker}"):
317
+ pattern = pattern[:-1]
301
318
  if pattern.endswith("}") and "{" in pattern:
302
319
  # Find the start of the exact quantifier and drop everything since that index
303
320
  idx = pattern.rfind("{")
@@ -41,11 +41,12 @@ from schemathesis.generation.case import Case
41
41
  from schemathesis.generation.meta import CaseMetadata
42
42
  from schemathesis.generation.overrides import Override, OverrideMark, check_no_override_mark
43
43
  from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
44
+ from schemathesis.specs.openapi.stateful import links
44
45
 
45
46
  from ...generation import GenerationConfig, GenerationMode
46
47
  from ...hooks import HookContext, HookDispatcher
47
48
  from ...schemas import APIOperation, APIOperationMap, ApiStatistic, BaseSchema, OperationDefinition
48
- from . import links, serialization
49
+ from . import serialization
49
50
  from ._cache import OperationCache
50
51
  from ._hypothesis import openapi_cases
51
52
  from .converter import to_json_schema, to_json_schema_recursive
@@ -155,7 +156,6 @@ class BaseOpenAPISchema(BaseSchema):
155
156
  return True
156
157
  if self.filter_set.is_empty():
157
158
  return False
158
- path = self.get_full_path(path)
159
159
  # Attribute assignment is way faster than creating a new namespace every time
160
160
  operation = _ctx_cache.operation
161
161
  operation.method = method
@@ -365,28 +365,24 @@ class BaseOpenAPISchema(BaseSchema):
365
365
  def _into_err(self, error: Exception, path: str | None, method: str | None) -> Err[InvalidSchema]:
366
366
  __tracebackhide__ = True
367
367
  try:
368
- full_path = self.get_full_path(path) if isinstance(path, str) else None
369
- self._raise_invalid_schema(error, full_path, path, method)
368
+ self._raise_invalid_schema(error, path, method)
370
369
  except InvalidSchema as exc:
371
370
  return Err(exc)
372
371
 
373
372
  def _raise_invalid_schema(
374
373
  self,
375
374
  error: Exception,
376
- full_path: str | None = None,
377
375
  path: str | None = None,
378
376
  method: str | None = None,
379
377
  ) -> NoReturn:
380
378
  __tracebackhide__ = True
381
379
  if isinstance(error, RefResolutionError):
382
- raise InvalidSchema.from_reference_resolution_error(
383
- error, path=path, method=method, full_path=full_path
384
- ) from None
380
+ raise InvalidSchema.from_reference_resolution_error(error, path=path, method=method) from None
385
381
  try:
386
382
  self.validate()
387
383
  except jsonschema.ValidationError as exc:
388
- raise InvalidSchema.from_jsonschema_error(exc, path=path, method=method, full_path=full_path) from None
389
- raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method, full_path=full_path) from error
384
+ raise InvalidSchema.from_jsonschema_error(exc, path=path, method=method) from None
385
+ raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method) from error
390
386
 
391
387
  def validate(self) -> None:
392
388
  with suppress(TypeError):
@@ -584,8 +580,7 @@ class BaseOpenAPISchema(BaseSchema):
584
580
  responses = operation.definition.raw["responses"]
585
581
  except KeyError as exc:
586
582
  path = operation.path
587
- full_path = self.get_full_path(path) if isinstance(path, str) else None
588
- self._raise_invalid_schema(exc, full_path, path, operation.method)
583
+ self._raise_invalid_schema(exc, path, operation.method)
589
584
  status_code = str(response.status_code)
590
585
  if status_code in responses:
591
586
  return self.resolver.resolve_in_scope(responses[status_code], operation.definition.scope)
@@ -654,7 +649,12 @@ class BaseOpenAPISchema(BaseSchema):
654
649
  def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
655
650
  result: dict[str, dict[str, Any]] = defaultdict(dict)
656
651
  for status_code, link in links.get_all_links(operation):
657
- result[status_code][link.name] = link
652
+ if isinstance(link, Ok):
653
+ name = link.ok().name
654
+ else:
655
+ name = link.err().name
656
+ result[status_code][name] = link
657
+
658
658
  return result
659
659
 
660
660
  def get_tags(self, operation: APIOperation) -> list[str] | None:
@@ -3,12 +3,24 @@ from __future__ import annotations
3
3
  import json
4
4
  from typing import Any, Callable, Dict, Generator, List
5
5
 
6
+ from schemathesis.schemas import APIOperation
7
+ from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
8
+
6
9
  Generated = Dict[str, Any]
7
10
  Definition = Dict[str, Any]
8
11
  DefinitionList = List[Definition]
9
12
  MapFunction = Callable[[Generated], Generated]
10
13
 
11
14
 
15
+ def get_serializers_for_operation(operation: APIOperation) -> dict[str, Callable]:
16
+ serializers = {}
17
+ for location, container in LOCATION_TO_CONTAINER.items():
18
+ serializer = operation.get_parameter_serializer(location)
19
+ if serializer is not None:
20
+ serializers[container] = serializer
21
+ return serializers
22
+
23
+
12
24
  def make_serializer(
13
25
  func: Callable[[DefinitionList], Generator[Callable | None, None, None]],
14
26
  ) -> Callable[[DefinitionList], Callable | None]:
@@ -16,6 +28,8 @@ def make_serializer(
16
28
 
17
29
  def _wrapper(definitions: DefinitionList) -> Callable | None:
18
30
  functions = list(func(definitions))
31
+ if not functions:
32
+ return None
19
33
 
20
34
  def composed(x: Any) -> Any:
21
35
  result = x