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
@@ -22,9 +22,6 @@ from schemathesis.openapi.checks import (
22
22
  MalformedMediaType,
23
23
  MissingContentType,
24
24
  MissingHeaders,
25
- MissingRequiredHeaderConfig,
26
- NegativeDataRejectionConfig,
27
- PositiveDataAcceptanceConfig,
28
25
  RejectedPositiveData,
29
26
  UndefinedContentType,
30
27
  UndefinedStatusCode,
@@ -185,7 +182,7 @@ def response_headers_conformance(ctx: CheckContext, response: Response, case: Ca
185
182
  title="Response header does not conform to the schema",
186
183
  operation=case.operation.label,
187
184
  exc=exc,
188
- output_config=case.operation.schema.output_config,
185
+ config=case.operation.schema.config.output,
189
186
  )
190
187
  )
191
188
  return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
@@ -233,8 +230,8 @@ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -
233
230
  ):
234
231
  return True
235
232
 
236
- config = ctx.config.get(negative_data_rejection, NegativeDataRejectionConfig())
237
- allowed_statuses = expand_status_codes(config.allowed_statuses or [])
233
+ config = ctx.config.negative_data_rejection
234
+ allowed_statuses = expand_status_codes(config.expected_statuses or [])
238
235
 
239
236
  if (
240
237
  case.meta.generation.mode.is_negative
@@ -243,9 +240,9 @@ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -
243
240
  ):
244
241
  raise AcceptedNegativeData(
245
242
  operation=case.operation.label,
246
- message=f"Allowed statuses: {', '.join(config.allowed_statuses)}",
243
+ message=f"Allowed statuses: {', '.join(config.expected_statuses)}",
247
244
  status_code=response.status_code,
248
- allowed_statuses=config.allowed_statuses,
245
+ expected_statuses=config.expected_statuses,
249
246
  )
250
247
  return None
251
248
 
@@ -261,21 +258,21 @@ def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case)
261
258
  ):
262
259
  return True
263
260
 
264
- config = ctx.config.get(positive_data_acceptance, PositiveDataAcceptanceConfig())
265
- allowed_statuses = expand_status_codes(config.allowed_statuses or [])
261
+ config = ctx.config.positive_data_acceptance
262
+ allowed_statuses = expand_status_codes(config.expected_statuses or [])
266
263
 
267
264
  if case.meta.generation.mode.is_positive and response.status_code not in allowed_statuses:
268
265
  raise RejectedPositiveData(
269
266
  operation=case.operation.label,
270
- message=f"Allowed statuses: {', '.join(config.allowed_statuses)}",
267
+ message=f"Allowed statuses: {', '.join(config.expected_statuses)}",
271
268
  status_code=response.status_code,
272
- allowed_statuses=config.allowed_statuses,
269
+ allowed_statuses=config.expected_statuses,
273
270
  )
274
271
  return None
275
272
 
276
273
 
274
+ @schemathesis.check
277
275
  def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
278
- # NOTE: This check is intentionally not registered with `@schemathesis.check` because it is experimental
279
276
  meta = case.meta
280
277
  if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or is_unexpected_http_status_case(case):
281
278
  return None
@@ -287,16 +284,17 @@ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -
287
284
  and data.description.startswith("Missing ")
288
285
  ):
289
286
  if data.parameter.lower() == "authorization":
290
- allowed_statuses = {401}
287
+ expected_statuses = {401}
291
288
  else:
292
- config = ctx.config.get(missing_required_header, MissingRequiredHeaderConfig())
293
- allowed_statuses = expand_status_codes(config.allowed_statuses or [])
294
- if response.status_code not in allowed_statuses:
295
- allowed = f"Allowed statuses: {', '.join(map(str, allowed_statuses))}"
289
+ config = ctx.config.missing_required_header
290
+ expected_statuses = expand_status_codes(config.expected_statuses or [])
291
+ if response.status_code not in expected_statuses:
292
+ allowed = f"Allowed statuses: {', '.join(map(str, expected_statuses))}"
296
293
  raise AssertionError(f"Unexpected response status for a missing header: {response.status_code}\n{allowed}")
297
294
  return None
298
295
 
299
296
 
297
+ @schemathesis.check
300
298
  def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
301
299
  meta = case.meta
302
300
  if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or response.request.method == "OPTIONS":
@@ -354,12 +352,12 @@ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool |
354
352
  if response.status_code == 404 or response.status_code >= 500:
355
353
  return None
356
354
 
357
- for related_case in ctx.find_related(case_id=case.id):
358
- parent = ctx.find_parent(case_id=related_case.id)
355
+ for related_case in ctx._find_related(case_id=case.id):
356
+ parent = ctx._find_parent(case_id=related_case.id)
359
357
  if not parent:
360
358
  continue
361
359
 
362
- parent_response = ctx.find_response(case_id=parent.id)
360
+ parent_response = ctx._find_response(case_id=parent.id)
363
361
 
364
362
  if (
365
363
  related_case.operation.method.lower() == "delete"
@@ -397,10 +395,10 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
397
395
  if not (400 <= response.status_code < 500):
398
396
  return None
399
397
 
400
- parent = ctx.find_parent(case_id=case.id)
398
+ parent = ctx._find_parent(case_id=case.id)
401
399
  if parent is None:
402
400
  return None
403
- parent_response = ctx.find_response(case_id=parent.id)
401
+ parent_response = ctx._find_response(case_id=parent.id)
404
402
  if parent_response is None:
405
403
  return None
406
404
 
@@ -426,8 +424,8 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
426
424
  return None
427
425
 
428
426
  # Look for any successful DELETE operations on this resource
429
- for related_case in ctx.find_related(case_id=case.id):
430
- related_response = ctx.find_response(case_id=related_case.id)
427
+ for related_case in ctx._find_related(case_id=case.id):
428
+ related_response = ctx._find_response(case_id=related_case.id)
431
429
  if (
432
430
  related_case.operation.method.upper() == "DELETE"
433
431
  and related_response is not None
@@ -480,25 +478,25 @@ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | No
480
478
  # Auth is explicitly set, it is expected to be valid
481
479
  # Check if invalid auth will give an error
482
480
  no_auth_case = remove_auth(case, security_parameters)
483
- kwargs = ctx.transport_kwargs or {}
481
+ kwargs = ctx._transport_kwargs or {}
484
482
  kwargs.copy()
485
483
  if "headers" in kwargs:
486
484
  headers = kwargs["headers"].copy()
487
485
  _remove_auth_from_explicit_headers(headers, security_parameters)
488
486
  kwargs["headers"] = headers
489
487
  kwargs.pop("session", None)
490
- ctx.record_case(parent_id=case.id, case=no_auth_case)
488
+ ctx._record_case(parent_id=case.id, case=no_auth_case)
491
489
  no_auth_response = case.operation.schema.transport.send(no_auth_case, **kwargs)
492
- ctx.record_response(case_id=no_auth_case.id, response=no_auth_response)
490
+ ctx._record_response(case_id=no_auth_case.id, response=no_auth_response)
493
491
  if no_auth_response.status_code != 401:
494
492
  _raise_no_auth_error(no_auth_response, no_auth_case, "that requires authentication")
495
493
  # Try to set invalid auth and check if it succeeds
496
494
  for parameter in security_parameters:
497
495
  invalid_auth_case = remove_auth(case, security_parameters)
498
496
  _set_auth_for_case(invalid_auth_case, parameter)
499
- ctx.record_case(parent_id=case.id, case=invalid_auth_case)
497
+ ctx._record_case(parent_id=case.id, case=invalid_auth_case)
500
498
  invalid_auth_response = case.operation.schema.transport.send(invalid_auth_case, **kwargs)
501
- ctx.record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
499
+ ctx._record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
502
500
  if invalid_auth_response.status_code != 401:
503
501
  _raise_no_auth_error(invalid_auth_response, invalid_auth_case, "with any auth")
504
502
  elif auth == AuthKind.GENERATED:
@@ -542,7 +540,7 @@ def _contains_auth(
542
540
  from requests.cookies import RequestsCookieJar
543
541
 
544
542
  # If auth comes from explicit `auth` option or a custom auth, it is always explicit
545
- if ctx.auth is not None or case._has_explicit_auth:
543
+ if ctx._auth is not None or case._has_explicit_auth:
546
544
  return AuthKind.EXPLICIT
547
545
  parsed = urlparse(request.url)
548
546
  query = parse_qs(parsed.query) # type: ignore
@@ -565,19 +563,19 @@ def _contains_auth(
565
563
  for parameter in security_parameters:
566
564
  name = parameter["name"]
567
565
  if has_header(parameter):
568
- if (ctx.headers is not None and name in ctx.headers) or (ctx.override and name in ctx.override.headers):
566
+ if (ctx._headers is not None and name in ctx._headers) or (ctx._override and name in ctx._override.headers):
569
567
  return AuthKind.EXPLICIT
570
568
  return AuthKind.GENERATED
571
569
  if has_cookie(parameter):
572
- if ctx.headers is not None and "Cookie" in ctx.headers:
573
- cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
570
+ if ctx._headers is not None and "Cookie" in ctx._headers:
571
+ cookies = cast(RequestsCookieJar, ctx._headers["Cookie"]) # type: ignore
574
572
  if name in cookies:
575
573
  return AuthKind.EXPLICIT
576
- if ctx.override and name in ctx.override.cookies:
574
+ if ctx._override and name in ctx._override.cookies:
577
575
  return AuthKind.EXPLICIT
578
576
  return AuthKind.GENERATED
579
577
  if has_query(parameter):
580
- if ctx.override and name in ctx.override.query:
578
+ if ctx._override and name in ctx._override.query:
581
579
  return AuthKind.EXPLICIT
582
580
  return AuthKind.GENERATED
583
581
 
@@ -630,6 +628,9 @@ def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
630
628
  ):
631
629
  if parameter["in"] == location:
632
630
  container = getattr(case, attr_name, {})
631
+ # Could happen in the negative testing mode
632
+ if not isinstance(container, dict):
633
+ container = {}
633
634
  container[name] = "SCHEMATHESIS-INVALID-VALUE"
634
635
  setattr(case, attr_name, container)
635
636
 
@@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, Generator, Iterator, Union, cast
9
9
  import requests
10
10
  from hypothesis_jsonschema import from_schema
11
11
 
12
+ from schemathesis.config import GenerationConfig
12
13
  from schemathesis.core.transforms import deepclone
13
14
  from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
14
- from schemathesis.generation import GenerationConfig
15
15
  from schemathesis.generation.case import Case
16
16
  from schemathesis.generation.hypothesis import examples
17
17
  from schemathesis.generation.meta import TestPhase
@@ -68,7 +68,7 @@ def get_strategies_from_examples(
68
68
  # Add examples from parameter's schemas
69
69
  examples.extend(extract_from_schemas(operation))
70
70
  return [
71
- openapi_cases(operation=operation, **{**parameters, **kwargs, "phase": TestPhase.EXPLICIT}).map(
71
+ openapi_cases(operation=operation, **{**parameters, **kwargs, "phase": TestPhase.EXAMPLES}).map(
72
72
  serialize_components
73
73
  )
74
74
  for parameters in produce_combinations(examples)
@@ -274,12 +274,13 @@ def extract_from_schema(
274
274
  continue
275
275
  variants[name] = values
276
276
  if variants:
277
+ config = operation.schema.config.generation_for(operation=operation, phase="examples")
277
278
  for name, subschema in to_generate.items():
278
279
  if name in variants:
279
280
  # Generated by one of `anyOf` or similar sub-schemas
280
281
  continue
281
282
  subschema = operation.schema.prepare_schema(subschema)
282
- generated = _generate_single_example(subschema, operation.schema.generation_config)
283
+ generated = _generate_single_example(subschema, config)
283
284
  variants[name] = [generated]
284
285
  # Calculate the maximum number of examples any property has
285
286
  total_combos = max(len(examples) for examples in variants.values())
@@ -15,10 +15,37 @@ STRING_FORMATS: dict[str, st.SearchStrategy] = {}
15
15
 
16
16
 
17
17
  def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
18
- """Register a new strategy for generating data for specific string "format".
18
+ r"""Register a custom Hypothesis strategy for generating string format data.
19
+
20
+ Args:
21
+ name: String format name that matches the "format" keyword in your API schema
22
+ strategy: Hypothesis strategy to generate values for this format
23
+
24
+ Example:
25
+ ```python
26
+ import schemathesis
27
+ from hypothesis import strategies as st
28
+
29
+ # Register phone number format
30
+ phone_strategy = st.from_regex(r"\+1-\d{3}-\d{3}-\d{4}")
31
+ schemathesis.openapi.format("phone", phone_strategy)
32
+
33
+ # Register email with specific domain
34
+ email_strategy = st.from_regex(r"[a-z]+@company\.com")
35
+ schemathesis.openapi.format("company-email", email_strategy)
36
+ ```
37
+
38
+ Schema usage:
39
+ ```yaml
40
+ properties:
41
+ phone:
42
+ type: string
43
+ format: phone # Uses your phone_strategy
44
+ contact_email:
45
+ type: string
46
+ format: company-email # Uses your email_strategy
47
+ ```
19
48
 
20
- :param str name: Format name. It should correspond the one used in the API schema as the "format" keyword value.
21
- :param strategy: Hypothesis strategy you'd like to use to generate values for this format.
22
49
  """
23
50
  from hypothesis.strategies import SearchStrategy
24
51
 
@@ -38,11 +65,11 @@ def unregister_string_format(name: str) -> None:
38
65
  raise ValueError(f"Unknown Open API format: {name}") from exc
39
66
 
40
67
 
41
- def header_values(blacklist_characters: str = "\n\r") -> st.SearchStrategy[str]:
68
+ def header_values(exclude_characters: str = "\n\r") -> st.SearchStrategy[str]:
42
69
  from hypothesis import strategies as st
43
70
 
44
71
  return st.text(
45
- alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters=blacklist_characters)
72
+ alphabet=st.characters(min_codepoint=0, max_codepoint=255, exclude_characters=exclude_characters)
46
73
  # Header values with leading non-visible chars can't be sent with `requests`
47
74
  ).map(str.lstrip)
48
75
 
@@ -15,7 +15,50 @@ MEDIA_TYPES: dict[str, st.SearchStrategy[bytes]] = {}
15
15
 
16
16
 
17
17
  def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliases: Collection[str] = ()) -> None:
18
- """Register a strategy for the given media type."""
18
+ r"""Register a custom Hypothesis strategy for generating media type content.
19
+
20
+ Args:
21
+ name: Media type name that matches your OpenAPI requestBody content type
22
+ strategy: Hypothesis strategy that generates bytes for this media type
23
+ aliases: Additional media type names that use the same strategy
24
+
25
+ Example:
26
+ ```python
27
+ import schemathesis
28
+ from hypothesis import strategies as st
29
+
30
+ # Register PDF file strategy
31
+ pdf_strategy = st.sampled_from([
32
+ b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF",
33
+ b"%PDF-1.5\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF"
34
+ ])
35
+ schemathesis.openapi.media_type("application/pdf", pdf_strategy)
36
+
37
+ # Dynamic content generation
38
+ @st.composite
39
+ def xml_content(draw):
40
+ tag = draw(st.text(min_size=3, max_size=10))
41
+ content = draw(st.text(min_size=1, max_size=50))
42
+ return f"<?xml version='1.0'?><{tag}>{content}</{tag}>".encode()
43
+
44
+ schemathesis.openapi.media_type("application/xml", xml_content())
45
+ ```
46
+
47
+ Schema usage:
48
+ ```yaml
49
+ requestBody:
50
+ content:
51
+ application/pdf: # Uses your PDF strategy
52
+ schema:
53
+ type: string
54
+ format: binary
55
+ application/xml: # Uses your XML strategy
56
+ schema:
57
+ type: string
58
+ format: binary
59
+ ```
60
+
61
+ """
19
62
 
20
63
  @REQUESTS_TRANSPORT.serializer(name, *aliases)
21
64
  @ASGI_TRANSPORT.serializer(name, *aliases)
@@ -9,12 +9,12 @@ import jsonschema
9
9
  from hypothesis import strategies as st
10
10
  from hypothesis_jsonschema import from_schema
11
11
 
12
+ from schemathesis.config import GenerationConfig
13
+
12
14
  from ..constants import ALL_KEYWORDS
13
15
  from .mutations import MutationContext
14
16
 
15
17
  if TYPE_CHECKING:
16
- from schemathesis.generation import GenerationConfig
17
-
18
18
  from .types import Draw, Schema
19
19
 
20
20
 
@@ -19,6 +19,7 @@ if hasattr(sre, "POSSESSIVE_REPEAT"):
19
19
  else:
20
20
  REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT)
21
21
  LITERAL = sre.LITERAL
22
+ NOT_LITERAL = sre.NOT_LITERAL
22
23
  IN = sre.IN
23
24
  MAXREPEAT = sre_parse.MAXREPEAT
24
25
 
@@ -114,8 +115,20 @@ def _handle_anchored_pattern(parsed: list, pattern: str, min_length: int | None,
114
115
 
115
116
  pattern_parts = parsed[1:-1]
116
117
 
118
+ # Calculate total fixed length and per-repetition lengths
119
+ fixed_length = 0
120
+ quantifier_bounds = []
121
+ repetition_lengths = []
122
+
123
+ for op, value in pattern_parts:
124
+ if op in (LITERAL, NOT_LITERAL):
125
+ fixed_length += 1
126
+ elif op in REPEATS:
127
+ min_repeat, max_repeat, subpattern = value
128
+ quantifier_bounds.append((min_repeat, max_repeat))
129
+ repetition_lengths.append(_calculate_min_repetition_length(subpattern))
130
+
117
131
  # Adjust length constraints by subtracting fixed literals length
118
- fixed_length = sum(1 for op, _ in pattern_parts if op == LITERAL)
119
132
  if min_length is not None:
120
133
  min_length -= fixed_length
121
134
  if min_length < 0:
@@ -125,13 +138,10 @@ def _handle_anchored_pattern(parsed: list, pattern: str, min_length: int | None,
125
138
  if max_length < 0:
126
139
  return pattern
127
140
 
128
- # Extract only min/max bounds from quantified parts
129
- quantifier_bounds = [value[:2] for op, value in pattern_parts if op in REPEATS]
130
-
131
141
  if not quantifier_bounds:
132
142
  return pattern
133
143
 
134
- length_distribution = _distribute_length_constraints(quantifier_bounds, min_length, max_length)
144
+ length_distribution = _distribute_length_constraints(quantifier_bounds, repetition_lengths, min_length, max_length)
135
145
  if not length_distribution:
136
146
  return pattern
137
147
 
@@ -212,7 +222,7 @@ def _find_quantified_end(pattern: str, start: int) -> int:
212
222
 
213
223
 
214
224
  def _distribute_length_constraints(
215
- bounds: list[tuple[int, int]], min_length: int | None, max_length: int | None
225
+ bounds: list[tuple[int, int]], repetition_lengths: list[int], min_length: int | None, max_length: int | None
216
226
  ) -> list[tuple[int, int]] | None:
217
227
  """Distribute length constraints among quantified pattern parts."""
218
228
  # Handle exact length case with dynamic programming
@@ -228,18 +238,22 @@ def _distribute_length_constraints(
228
238
  if pos == len(bounds):
229
239
  return [()] if remaining == 0 else None
230
240
 
231
- max_len: int
232
- min_len, max_len = bounds[pos]
233
- if max_len == MAXREPEAT:
234
- max_len = remaining + 1
235
- else:
236
- max_len += 1
241
+ max_repeat: int
242
+ min_repeat, max_repeat = bounds[pos]
243
+ repeat_length = repetition_lengths[pos]
244
+
245
+ if max_repeat == MAXREPEAT:
246
+ max_repeat = remaining // repeat_length + 1 if repeat_length > 0 else remaining + 1
237
247
 
238
248
  # Try each possible length for current quantifier
239
- for length in range(min_len, max_len):
240
- rest = find_valid_combination(pos + 1, remaining - length)
249
+ for repeat_count in range(min_repeat, max_repeat + 1):
250
+ used_length = repeat_count * repeat_length
251
+ if used_length > remaining:
252
+ break
253
+
254
+ rest = find_valid_combination(pos + 1, remaining - used_length)
241
255
  if rest is not None:
242
- dp[(pos, remaining)] = [(length,) + r for r in rest]
256
+ dp[(pos, remaining)] = [(repeat_count,) + r for r in rest]
243
257
  return dp[(pos, remaining)]
244
258
 
245
259
  dp[(pos, remaining)] = None
@@ -280,6 +294,22 @@ def _distribute_length_constraints(
280
294
  return result
281
295
 
282
296
 
297
+ def _calculate_min_repetition_length(subpattern: list) -> int:
298
+ """Calculate minimum length contribution per repetition of a quantified group."""
299
+ total = 0
300
+ for op, value in subpattern:
301
+ if op in [LITERAL, NOT_LITERAL, IN, sre.ANY]:
302
+ total += 1
303
+ elif op == sre.SUBPATTERN:
304
+ _, _, _, inner_pattern = value
305
+ total += _calculate_min_repetition_length(inner_pattern)
306
+ elif op in REPEATS:
307
+ min_repeat, _, inner_pattern = value
308
+ inner_min = _calculate_min_repetition_length(inner_pattern)
309
+ total += min_repeat * inner_min
310
+ return total
311
+
312
+
283
313
  def _get_anchor_length(node_type: int) -> int:
284
314
  """Determine the length of the anchor based on its type."""
285
315
  if node_type in {sre.AT_BEGINNING_STRING, sre.AT_END_STRING, sre.AT_BOUNDARY, sre.AT_NON_BOUNDARY}:
@@ -293,7 +323,7 @@ def _update_quantifier(
293
323
  """Update the quantifier based on the operation type and given constraints."""
294
324
  if op in REPEATS and value is not None:
295
325
  return _handle_repeat_quantifier(value, pattern, min_length, max_length)
296
- if op in (LITERAL, IN) and max_length != 0:
326
+ if op in (LITERAL, NOT_LITERAL, IN) and max_length != 0:
297
327
  return _handle_literal_or_in_quantifier(pattern, min_length, max_length)
298
328
  if op == sre.ANY and value is None:
299
329
  # Equivalent to `.` which is in turn is the same as `.{1}`
@@ -5,10 +5,9 @@ from functools import lru_cache
5
5
  from typing import Any, Callable, Dict, Union, overload
6
6
  from urllib.request import urlopen
7
7
 
8
- import jsonschema
9
8
  import requests
10
9
 
11
- from schemathesis.core.compat import RefResolutionError
10
+ from schemathesis.core.compat import RefResolutionError, RefResolver
12
11
  from schemathesis.core.deserialization import deserialize_yaml
13
12
  from schemathesis.core.transforms import deepclone
14
13
  from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
@@ -48,7 +47,7 @@ def load_remote_uri(uri: str) -> Any:
48
47
  JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
49
48
 
50
49
 
51
- class InliningResolver(jsonschema.RefResolver):
50
+ class InliningResolver(RefResolver):
52
51
  """Inlines resolved schemas."""
53
52
 
54
53
  def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -43,7 +43,7 @@ from schemathesis.generation.overrides import Override, OverrideMark, check_no_o
43
43
  from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
44
44
  from schemathesis.specs.openapi.stateful import links
45
45
 
46
- from ...generation import GenerationConfig, GenerationMode
46
+ from ...generation import GenerationMode
47
47
  from ...hooks import HookContext, HookDispatcher
48
48
  from ...schemas import APIOperation, APIOperationMap, ApiStatistic, BaseSchema, OperationDefinition
49
49
  from . import serialization
@@ -120,13 +120,18 @@ class BaseOpenAPISchema(BaseSchema):
120
120
  if map is not None:
121
121
  return map
122
122
  path_item = self.raw_schema.get("paths", {})[path]
123
- scope, path_item = self._resolve_path_item(path_item)
123
+ with in_scope(self.resolver, self.location or ""):
124
+ scope, path_item = self._resolve_path_item(path_item)
124
125
  self.dispatch_hook("before_process_path", HookContext(), path, path_item)
125
126
  map = APIOperationMap(self, {})
126
127
  map._data = MethodMap(map, scope, path, CaseInsensitiveDict(path_item))
127
128
  cache.insert_map(path, map)
128
129
  return map
129
130
 
131
+ def find_operation_by_label(self, label: str) -> APIOperation | None:
132
+ method, path = label.split(" ", maxsplit=1)
133
+ return self[path][method]
134
+
130
135
  def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
131
136
  matches = get_close_matches(item, list(self))
132
137
  self._on_missing_operation(item, exc, matches)
@@ -292,9 +297,7 @@ class BaseOpenAPISchema(BaseSchema):
292
297
  parameters = operation.get("parameters", ())
293
298
  return self.collect_parameters(itertools.chain(parameters, shared_parameters), operation)
294
299
 
295
- def get_all_operations(
296
- self, generation_config: GenerationConfig | None = None
297
- ) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
300
+ def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
298
301
  """Iterate over all operations defined in the API.
299
302
 
300
303
  Each yielded item is either `Ok` or `Err`, depending on the presence of errors during schema processing.
@@ -352,9 +355,6 @@ class BaseOpenAPISchema(BaseSchema):
352
355
  entry,
353
356
  resolved,
354
357
  scope,
355
- with_security_parameters=generation_config.with_security_parameters
356
- if generation_config
357
- else None,
358
358
  )
359
359
  yield Ok(operation)
360
360
  except SCHEMA_PARSING_ERRORS as exc:
@@ -381,7 +381,9 @@ class BaseOpenAPISchema(BaseSchema):
381
381
  try:
382
382
  self.validate()
383
383
  except jsonschema.ValidationError as exc:
384
- raise InvalidSchema.from_jsonschema_error(exc, path=path, method=method) from None
384
+ raise InvalidSchema.from_jsonschema_error(
385
+ exc, path=path, method=method, config=self.config.output
386
+ ) from None
385
387
  raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method) from error
386
388
 
387
389
  def validate(self) -> None:
@@ -417,7 +419,6 @@ class BaseOpenAPISchema(BaseSchema):
417
419
  raw: dict[str, Any],
418
420
  resolved: dict[str, Any],
419
421
  scope: str,
420
- with_security_parameters: bool | None = None,
421
422
  ) -> APIOperation:
422
423
  """Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
423
424
  __tracebackhide__ = True
@@ -432,12 +433,8 @@ class BaseOpenAPISchema(BaseSchema):
432
433
  )
433
434
  for parameter in parameters:
434
435
  operation.add_parameter(parameter)
435
- with_security_parameters = (
436
- with_security_parameters
437
- if with_security_parameters is not None
438
- else self.generation_config.with_security_parameters
439
- )
440
- if with_security_parameters:
436
+ config = self.config.generation_for(operation=operation)
437
+ if config.with_security_parameters:
441
438
  self.security.process_definitions(self.raw_schema, operation, self.resolver)
442
439
  self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
443
440
  return operation
@@ -544,22 +541,21 @@ class BaseOpenAPISchema(BaseSchema):
544
541
  operation: APIOperation,
545
542
  hooks: HookDispatcher | None = None,
546
543
  auth_storage: AuthStorage | None = None,
547
- generation_mode: GenerationMode = GenerationMode.default(),
548
- generation_config: GenerationConfig | None = None,
544
+ generation_mode: GenerationMode = GenerationMode.POSITIVE,
549
545
  **kwargs: Any,
550
546
  ) -> SearchStrategy:
551
547
  return openapi_cases(
552
548
  operation=operation,
553
- auth_storage=auth_storage,
554
549
  hooks=hooks,
550
+ auth_storage=auth_storage,
555
551
  generation_mode=generation_mode,
556
- generation_config=generation_config or self.generation_config,
557
552
  **kwargs,
558
553
  )
559
554
 
560
555
  def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
561
556
  definitions = [item.definition for item in operation.iter_parameters() if item.location == location]
562
- if self.generation_config.with_security_parameters:
557
+ config = self.config.generation_for(operation=operation)
558
+ if config.with_security_parameters:
563
559
  security_parameters = self.security.get_security_definitions_as_parameters(
564
560
  self.raw_schema, operation, self.resolver, location
565
561
  )
@@ -667,6 +663,7 @@ class BaseOpenAPISchema(BaseSchema):
667
663
  return jsonschema.Draft4Validator
668
664
 
669
665
  def validate_response(self, operation: APIOperation, response: Response) -> bool | None:
666
+ __tracebackhide__ = True
670
667
  responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
671
668
  status_code = str(response.status_code)
672
669
  if status_code in responses:
@@ -713,7 +710,7 @@ class BaseOpenAPISchema(BaseSchema):
713
710
  JsonSchemaError.from_exception(
714
711
  operation=operation.label,
715
712
  exc=exc,
716
- output_config=operation.schema.output_config,
713
+ config=operation.schema.config.output,
717
714
  )
718
715
  )
719
716
  _maybe_raise_one_or_more(failures)