schemathesis 3.25.5__py3-none-any.whl → 3.39.7__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 (146) hide show
  1. schemathesis/__init__.py +6 -6
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +4 -2
  4. schemathesis/_hypothesis.py +369 -56
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +5 -4
  7. schemathesis/_patches.py +21 -0
  8. schemathesis/_rate_limiter.py +7 -0
  9. schemathesis/_xml.py +75 -22
  10. schemathesis/auths.py +78 -16
  11. schemathesis/checks.py +21 -9
  12. schemathesis/cli/__init__.py +793 -448
  13. schemathesis/cli/__main__.py +4 -0
  14. schemathesis/cli/callbacks.py +58 -13
  15. schemathesis/cli/cassettes.py +233 -47
  16. schemathesis/cli/constants.py +8 -2
  17. schemathesis/cli/context.py +24 -4
  18. schemathesis/cli/debug.py +2 -1
  19. schemathesis/cli/handlers.py +4 -1
  20. schemathesis/cli/junitxml.py +103 -22
  21. schemathesis/cli/options.py +15 -4
  22. schemathesis/cli/output/default.py +286 -115
  23. schemathesis/cli/output/short.py +25 -6
  24. schemathesis/cli/reporting.py +79 -0
  25. schemathesis/cli/sanitization.py +6 -0
  26. schemathesis/code_samples.py +5 -3
  27. schemathesis/constants.py +1 -0
  28. schemathesis/contrib/openapi/__init__.py +1 -1
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
  30. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  31. schemathesis/contrib/unique_data.py +3 -3
  32. schemathesis/exceptions.py +76 -65
  33. schemathesis/experimental/__init__.py +35 -0
  34. schemathesis/extra/_aiohttp.py +1 -0
  35. schemathesis/extra/_flask.py +4 -1
  36. schemathesis/extra/_server.py +1 -0
  37. schemathesis/extra/pytest_plugin.py +17 -25
  38. schemathesis/failures.py +77 -9
  39. schemathesis/filters.py +185 -8
  40. schemathesis/fixups/__init__.py +1 -0
  41. schemathesis/fixups/fast_api.py +2 -2
  42. schemathesis/fixups/utf8_bom.py +1 -2
  43. schemathesis/generation/__init__.py +20 -36
  44. schemathesis/generation/_hypothesis.py +59 -0
  45. schemathesis/generation/_methods.py +44 -0
  46. schemathesis/generation/coverage.py +931 -0
  47. schemathesis/graphql.py +0 -1
  48. schemathesis/hooks.py +89 -12
  49. schemathesis/internal/checks.py +84 -0
  50. schemathesis/internal/copy.py +22 -3
  51. schemathesis/internal/deprecation.py +6 -2
  52. schemathesis/internal/diff.py +15 -0
  53. schemathesis/internal/extensions.py +27 -0
  54. schemathesis/internal/jsonschema.py +2 -1
  55. schemathesis/internal/output.py +68 -0
  56. schemathesis/internal/result.py +1 -1
  57. schemathesis/internal/transformation.py +11 -0
  58. schemathesis/lazy.py +138 -25
  59. schemathesis/loaders.py +7 -5
  60. schemathesis/models.py +323 -213
  61. schemathesis/parameters.py +4 -0
  62. schemathesis/runner/__init__.py +72 -22
  63. schemathesis/runner/events.py +86 -6
  64. schemathesis/runner/impl/context.py +104 -0
  65. schemathesis/runner/impl/core.py +447 -187
  66. schemathesis/runner/impl/solo.py +19 -29
  67. schemathesis/runner/impl/threadpool.py +70 -79
  68. schemathesis/{cli → runner}/probes.py +37 -25
  69. schemathesis/runner/serialization.py +150 -17
  70. schemathesis/sanitization.py +5 -1
  71. schemathesis/schemas.py +170 -102
  72. schemathesis/serializers.py +17 -4
  73. schemathesis/service/ci.py +1 -0
  74. schemathesis/service/client.py +39 -6
  75. schemathesis/service/events.py +5 -1
  76. schemathesis/service/extensions.py +224 -0
  77. schemathesis/service/hosts.py +6 -2
  78. schemathesis/service/metadata.py +25 -0
  79. schemathesis/service/models.py +211 -2
  80. schemathesis/service/report.py +6 -6
  81. schemathesis/service/serialization.py +60 -71
  82. schemathesis/service/usage.py +1 -0
  83. schemathesis/specs/graphql/_cache.py +26 -0
  84. schemathesis/specs/graphql/loaders.py +25 -5
  85. schemathesis/specs/graphql/nodes.py +1 -0
  86. schemathesis/specs/graphql/scalars.py +2 -2
  87. schemathesis/specs/graphql/schemas.py +130 -100
  88. schemathesis/specs/graphql/validation.py +1 -2
  89. schemathesis/specs/openapi/__init__.py +1 -0
  90. schemathesis/specs/openapi/_cache.py +123 -0
  91. schemathesis/specs/openapi/_hypothesis.py +79 -61
  92. schemathesis/specs/openapi/checks.py +504 -25
  93. schemathesis/specs/openapi/converter.py +31 -4
  94. schemathesis/specs/openapi/definitions.py +10 -17
  95. schemathesis/specs/openapi/examples.py +143 -31
  96. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  97. schemathesis/specs/openapi/expressions/context.py +1 -1
  98. schemathesis/specs/openapi/expressions/extractors.py +26 -0
  99. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  100. schemathesis/specs/openapi/expressions/nodes.py +29 -6
  101. schemathesis/specs/openapi/expressions/parser.py +26 -5
  102. schemathesis/specs/openapi/formats.py +44 -0
  103. schemathesis/specs/openapi/links.py +125 -42
  104. schemathesis/specs/openapi/loaders.py +77 -36
  105. schemathesis/specs/openapi/media_types.py +34 -0
  106. schemathesis/specs/openapi/negative/__init__.py +6 -3
  107. schemathesis/specs/openapi/negative/mutations.py +21 -6
  108. schemathesis/specs/openapi/parameters.py +39 -25
  109. schemathesis/specs/openapi/patterns.py +137 -0
  110. schemathesis/specs/openapi/references.py +37 -7
  111. schemathesis/specs/openapi/schemas.py +368 -242
  112. schemathesis/specs/openapi/security.py +25 -7
  113. schemathesis/specs/openapi/serialization.py +1 -0
  114. schemathesis/specs/openapi/stateful/__init__.py +198 -70
  115. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  116. schemathesis/specs/openapi/stateful/types.py +14 -0
  117. schemathesis/specs/openapi/utils.py +6 -1
  118. schemathesis/specs/openapi/validation.py +1 -0
  119. schemathesis/stateful/__init__.py +35 -21
  120. schemathesis/stateful/config.py +97 -0
  121. schemathesis/stateful/context.py +135 -0
  122. schemathesis/stateful/events.py +274 -0
  123. schemathesis/stateful/runner.py +309 -0
  124. schemathesis/stateful/sink.py +68 -0
  125. schemathesis/stateful/state_machine.py +67 -38
  126. schemathesis/stateful/statistic.py +22 -0
  127. schemathesis/stateful/validation.py +100 -0
  128. schemathesis/targets.py +33 -1
  129. schemathesis/throttling.py +25 -5
  130. schemathesis/transports/__init__.py +354 -0
  131. schemathesis/transports/asgi.py +7 -0
  132. schemathesis/transports/auth.py +25 -2
  133. schemathesis/transports/content_types.py +3 -1
  134. schemathesis/transports/headers.py +2 -1
  135. schemathesis/transports/responses.py +9 -4
  136. schemathesis/types.py +9 -0
  137. schemathesis/utils.py +11 -16
  138. schemathesis-3.39.7.dist-info/METADATA +293 -0
  139. schemathesis-3.39.7.dist-info/RECORD +160 -0
  140. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
  141. schemathesis/specs/openapi/filters.py +0 -49
  142. schemathesis/specs/openapi/stateful/links.py +0 -92
  143. schemathesis-3.25.5.dist-info/METADATA +0 -356
  144. schemathesis-3.25.5.dist-info/RECORD +0 -134
  145. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
  146. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
schemathesis/graphql.py CHANGED
@@ -1,4 +1,3 @@
1
- # Public API
2
1
  from .specs.graphql import nodes # noqa: F401
3
2
  from .specs.graphql.loaders import from_asgi, from_dict, from_file, from_path, from_url, from_wsgi # noqa: F401
4
3
  from .specs.graphql.scalars import scalar # noqa: F401
schemathesis/hooks.py CHANGED
@@ -1,20 +1,23 @@
1
1
  from __future__ import annotations
2
+
2
3
  import inspect
3
4
  from collections import defaultdict
4
5
  from copy import deepcopy
5
6
  from dataclasses import dataclass, field
6
7
  from enum import Enum, unique
7
8
  from functools import partial
8
- from typing import TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, cast
9
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast
9
10
 
10
- from .types import GenericTest
11
+ from .filters import FilterSet, attach_filter_chain
11
12
  from .internal.deprecation import deprecated_property
12
13
 
13
14
  if TYPE_CHECKING:
14
15
  from hypothesis import strategies as st
16
+
15
17
  from .models import APIOperation, Case
16
18
  from .schemas import BaseSchema
17
19
  from .transports.responses import GenericResponse
20
+ from .types import GenericTest
18
21
 
19
22
 
20
23
  @unique
@@ -29,6 +32,8 @@ class RegisteredHook:
29
32
  signature: inspect.Signature
30
33
  scopes: list[HookScope]
31
34
 
35
+ def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
36
+
32
37
 
33
38
  @dataclass
34
39
  class HookContext:
@@ -40,11 +45,63 @@ class HookContext:
40
45
 
41
46
  operation: APIOperation | None = None
42
47
 
43
- @deprecated_property(removed_in="4.0", replacement="operation")
48
+ @deprecated_property(removed_in="4.0", replacement="`operation`")
44
49
  def endpoint(self) -> APIOperation | None:
45
50
  return self.operation
46
51
 
47
52
 
53
+ def to_filterable_hook(dispatcher: HookDispatcher) -> Callable:
54
+ filter_used = False
55
+ filter_set = FilterSet()
56
+
57
+ def register(hook: str | Callable) -> Callable:
58
+ nonlocal filter_set
59
+
60
+ if filter_used:
61
+ validate_filterable_hook(hook)
62
+
63
+ if isinstance(hook, str):
64
+
65
+ def decorator(func: Callable) -> Callable:
66
+ hook_name = cast(str, hook)
67
+ if filter_used:
68
+ validate_filterable_hook(hook)
69
+ func.filter_set = filter_set # type: ignore[attr-defined]
70
+ return dispatcher.register_hook_with_name(func, hook_name)
71
+
72
+ init_filter_set(decorator)
73
+ return decorator
74
+
75
+ hook.filter_set = filter_set # type: ignore[attr-defined]
76
+ init_filter_set(register)
77
+ return dispatcher.register_hook_with_name(hook, hook.__name__)
78
+
79
+ def init_filter_set(target: Callable) -> FilterSet:
80
+ nonlocal filter_used
81
+
82
+ filter_used = False
83
+ filter_set = FilterSet()
84
+
85
+ def include(*args: Any, **kwargs: Any) -> None:
86
+ nonlocal filter_used
87
+
88
+ filter_used = True
89
+ filter_set.include(*args, **kwargs)
90
+
91
+ def exclude(*args: Any, **kwargs: Any) -> None:
92
+ nonlocal filter_used
93
+
94
+ filter_used = True
95
+ filter_set.exclude(*args, **kwargs)
96
+
97
+ attach_filter_chain(target, "apply_to", include)
98
+ attach_filter_chain(target, "skip_for", exclude)
99
+ return filter_set
100
+
101
+ filter_set = init_filter_set(register)
102
+ return register
103
+
104
+
48
105
  @dataclass
49
106
  class HookDispatcher:
50
107
  """Generic hook dispatcher.
@@ -53,9 +110,12 @@ class HookDispatcher:
53
110
  """
54
111
 
55
112
  scope: HookScope
56
- _hooks: DefaultDict[str, list[Callable]] = field(default_factory=lambda: defaultdict(list))
113
+ _hooks: defaultdict[str, list[Callable]] = field(default_factory=lambda: defaultdict(list))
57
114
  _specs: ClassVar[dict[str, RegisteredHook]] = {}
58
115
 
116
+ def __post_init__(self) -> None:
117
+ self.register = to_filterable_hook(self) # type: ignore[method-assign]
118
+
59
119
  def register(self, hook: str | Callable) -> Callable:
60
120
  """Register a new hook.
61
121
 
@@ -78,14 +138,7 @@ class HookDispatcher:
78
138
  def hook(context, strategy):
79
139
  ...
80
140
  """
81
- if isinstance(hook, str):
82
-
83
- def decorator(func: Callable) -> Callable:
84
- hook_name = cast(str, hook)
85
- return self.register_hook_with_name(func, hook_name)
86
-
87
- return decorator
88
- return self.register_hook_with_name(hook, hook.__name__)
141
+ raise NotImplementedError
89
142
 
90
143
  def merge(self, other: HookDispatcher) -> HookDispatcher:
91
144
  """Merge two dispatches together.
@@ -190,14 +243,22 @@ class HookDispatcher:
190
243
  self, strategy: st.SearchStrategy, container: str, context: HookContext
191
244
  ) -> st.SearchStrategy:
192
245
  for hook in self.get_all_by_name(f"before_generate_{container}"):
246
+ if _should_skip_hook(hook, context):
247
+ continue
193
248
  strategy = hook(context, strategy)
194
249
  for hook in self.get_all_by_name(f"filter_{container}"):
250
+ if _should_skip_hook(hook, context):
251
+ continue
195
252
  hook = partial(hook, context)
196
253
  strategy = strategy.filter(hook)
197
254
  for hook in self.get_all_by_name(f"map_{container}"):
255
+ if _should_skip_hook(hook, context):
256
+ continue
198
257
  hook = partial(hook, context)
199
258
  strategy = strategy.map(hook)
200
259
  for hook in self.get_all_by_name(f"flatmap_{container}"):
260
+ if _should_skip_hook(hook, context):
261
+ continue
201
262
  hook = partial(hook, context)
202
263
  strategy = strategy.flatmap(hook)
203
264
  return strategy
@@ -205,6 +266,8 @@ class HookDispatcher:
205
266
  def dispatch(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None:
206
267
  """Run all hooks for the given name."""
207
268
  for hook in self.get_all_by_name(name):
269
+ if _should_skip_hook(hook, context):
270
+ continue
208
271
  hook(context, *args, **kwargs)
209
272
 
210
273
  def unregister(self, hook: Callable) -> None:
@@ -224,6 +287,11 @@ class HookDispatcher:
224
287
  self._hooks = defaultdict(list)
225
288
 
226
289
 
290
+ def _should_skip_hook(hook: Callable, ctx: HookContext) -> bool:
291
+ filter_set = getattr(hook, "filter_set", None)
292
+ return filter_set is not None and ctx.operation is not None and not filter_set.match(ctx)
293
+
294
+
227
295
  def apply_to_all_dispatchers(
228
296
  operation: APIOperation,
229
297
  context: HookContext,
@@ -246,6 +314,15 @@ def should_skip_operation(dispatcher: HookDispatcher, context: HookContext) -> b
246
314
  return False
247
315
 
248
316
 
317
+ def validate_filterable_hook(hook: str | Callable) -> None:
318
+ if callable(hook):
319
+ name = hook.__name__
320
+ else:
321
+ name = hook
322
+ if name in ("before_process_path", "before_load_schema", "after_load_schema", "after_init_cli_run_handlers"):
323
+ raise ValueError(f"Filters are not applicable to this hook: `{name}`")
324
+
325
+
249
326
  all_scopes = HookDispatcher.register_spec(list(HookScope))
250
327
 
251
328
 
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import warnings
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Callable, Optional
7
+
8
+ if TYPE_CHECKING:
9
+ from requests.auth import HTTPDigestAuth
10
+ from requests.structures import CaseInsensitiveDict
11
+
12
+ from .._override import CaseOverride
13
+ from ..models import Case
14
+ from ..transports.responses import GenericResponse
15
+ from ..types import RawAuth
16
+
17
+
18
+ CheckFunction = Callable[["CheckContext", "GenericResponse", "Case"], Optional[bool]]
19
+
20
+
21
+ @dataclass
22
+ class NegativeDataRejectionConfig:
23
+ # 5xx will pass through
24
+ allowed_statuses: list[str] = field(default_factory=lambda: ["400", "401", "403", "404", "422", "428", "5xx"])
25
+
26
+
27
+ @dataclass
28
+ class PositiveDataAcceptanceConfig:
29
+ allowed_statuses: list[str] = field(default_factory=lambda: ["2xx", "401", "403", "404"])
30
+
31
+
32
+ @dataclass
33
+ class MissingRequiredHeaderConfig:
34
+ allowed_statuses: list[str] = field(default_factory=lambda: ["406"])
35
+
36
+
37
+ @dataclass
38
+ class CheckConfig:
39
+ missing_required_header: MissingRequiredHeaderConfig = field(default_factory=MissingRequiredHeaderConfig)
40
+ negative_data_rejection: NegativeDataRejectionConfig = field(default_factory=NegativeDataRejectionConfig)
41
+ positive_data_acceptance: PositiveDataAcceptanceConfig = field(default_factory=PositiveDataAcceptanceConfig)
42
+
43
+
44
+ @dataclass
45
+ class CheckContext:
46
+ """Context for Schemathesis checks.
47
+
48
+ Provides access to broader test execution data beyond individual test cases.
49
+ """
50
+
51
+ override: CaseOverride | None
52
+ auth: HTTPDigestAuth | RawAuth | None
53
+ headers: CaseInsensitiveDict | None
54
+ config: CheckConfig = field(default_factory=CheckConfig)
55
+ transport_kwargs: dict | None = None
56
+
57
+
58
+ def wrap_check(check: Callable) -> CheckFunction:
59
+ """Make older checks compatible with the new signature."""
60
+ signature = inspect.signature(check)
61
+ parameters = len(signature.parameters)
62
+
63
+ if parameters == 3:
64
+ # New style check, return as is
65
+ return check
66
+
67
+ if parameters == 2:
68
+ # Old style check, wrap it
69
+ warnings.warn(
70
+ f"The check function '{check.__name__}' uses an outdated signature. "
71
+ "Please update it to accept 'ctx' as the first argument: "
72
+ "(ctx: CheckContext, response: GenericResponse, case: Case) -> Optional[bool]",
73
+ DeprecationWarning,
74
+ stacklevel=2,
75
+ )
76
+
77
+ def wrapper(_: CheckContext, response: GenericResponse, case: Case) -> Optional[bool]:
78
+ return check(response, case)
79
+
80
+ wrapper.__name__ = check.__name__
81
+
82
+ return wrapper
83
+
84
+ raise ValueError(f"Invalid check function signature. Expected 2 or 3 parameters, got {parameters}")
@@ -1,13 +1,32 @@
1
1
  from typing import Any
2
2
 
3
+ from .extensions import extensible
3
4
 
5
+
6
+ @extensible("SCHEMATHESIS_EXTENSION_FAST_DEEP_COPY")
4
7
  def fast_deepcopy(value: Any) -> Any:
5
- """A specialized version of `deepcopy` that copies only `dict` and `list`.
8
+ """A specialized version of `deepcopy` that copies only `dict` and `list` and does unrolling.
6
9
 
7
10
  It is on average 3x faster than `deepcopy` and given the amount of calls, it is an important optimization.
8
11
  """
9
12
  if isinstance(value, dict):
10
- return {key: fast_deepcopy(v) for key, v in value.items()}
13
+ return {
14
+ k1: (
15
+ {k2: fast_deepcopy(v2) for k2, v2 in v1.items()}
16
+ if isinstance(v1, dict)
17
+ else [fast_deepcopy(v2) for v2 in v1]
18
+ if isinstance(v1, list)
19
+ else v1
20
+ )
21
+ for k1, v1 in value.items()
22
+ }
11
23
  if isinstance(value, list):
12
- return [fast_deepcopy(v) for v in value]
24
+ return [
25
+ {k2: fast_deepcopy(v2) for k2, v2 in v1.items()}
26
+ if isinstance(v1, dict)
27
+ else [fast_deepcopy(v2) for v2 in v1]
28
+ if isinstance(v1, list)
29
+ else v1
30
+ for v1 in value
31
+ ]
13
32
  return value
@@ -1,11 +1,11 @@
1
1
  import warnings
2
- from typing import Callable, Any
2
+ from typing import Any, Callable
3
3
 
4
4
 
5
5
  def _warn_deprecation(*, kind: str, thing: str, removed_in: str, replacement: str) -> None:
6
6
  warnings.warn(
7
7
  f"{kind} `{thing}` is deprecated and will be removed in Schemathesis {removed_in}. "
8
- f"Use `{replacement}` instead.",
8
+ f"Use {replacement} instead.",
9
9
  DeprecationWarning,
10
10
  stacklevel=1,
11
11
  )
@@ -23,6 +23,10 @@ def deprecated_property(*, removed_in: str, replacement: str) -> Callable:
23
23
  return wrapper
24
24
 
25
25
 
26
+ def warn_filtration_arguments(name: str) -> None:
27
+ _warn_deprecation(kind="Argument", thing=name, removed_in="4.0", replacement="`include` and `exclude` methods")
28
+
29
+
26
30
  def deprecated_function(*, removed_in: str, replacement: str) -> Callable:
27
31
  def wrapper(func: Callable) -> Callable:
28
32
  def inner(*args: Any, **kwargs: Any) -> Any:
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+
6
+ def diff(left: Mapping[str, Any], right: Mapping[str, Any]) -> dict[str, Any]:
7
+ """Calculate the difference between two dictionaries."""
8
+ diff = {}
9
+ for key, value in right.items():
10
+ if key not in left or left[key] != value:
11
+ diff[key] = value
12
+ for key in left:
13
+ if key not in right:
14
+ diff[key] = None # Mark deleted items as None
15
+ return diff
@@ -0,0 +1,27 @@
1
+ import os
2
+ from typing import Any, Callable
3
+
4
+
5
+ class ExtensionLoadingError(ImportError):
6
+ """Raised when an extension cannot be loaded."""
7
+
8
+
9
+ def import_extension(path: str) -> Any:
10
+ try:
11
+ module, item = path.rsplit(".", 1)
12
+ imported = __import__(module, fromlist=[item])
13
+ return getattr(imported, item)
14
+ except ValueError as exc:
15
+ raise ExtensionLoadingError(f"Invalid path: {path}") from exc
16
+ except (ImportError, AttributeError) as exc:
17
+ raise ExtensionLoadingError(f"Could not import {path}") from exc
18
+
19
+
20
+ def extensible(env_var: str) -> Callable[[Any], Any]:
21
+ def decorator(item: Any) -> Any:
22
+ path = os.getenv(env_var)
23
+ if path is not None:
24
+ return import_extension(path)
25
+ return item
26
+
27
+ return decorator
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
- from typing import overload, Dict, Union, Any, List, Callable
2
+
3
+ from typing import Any, Callable, Dict, List, Union, overload
3
4
 
4
5
  JsonValue = Union[Dict[str, Any], List, str, float, int]
5
6
 
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, replace
5
+ from typing import Any
6
+
7
+ TRUNCATED = "// Output truncated..."
8
+ MAX_PAYLOAD_SIZE = 512
9
+ MAX_LINES = 10
10
+ MAX_WIDTH = 80
11
+
12
+
13
+ @dataclass
14
+ class OutputConfig:
15
+ """Options for configuring various aspects of Schemathesis output."""
16
+
17
+ truncate: bool = True
18
+ max_payload_size: int = MAX_PAYLOAD_SIZE
19
+ max_lines: int = MAX_LINES
20
+ max_width: int = MAX_WIDTH
21
+
22
+ @classmethod
23
+ def from_parent(cls, parent: OutputConfig | None = None, **changes: Any) -> OutputConfig:
24
+ parent = parent or OutputConfig()
25
+ return parent.replace(**changes)
26
+
27
+ def replace(self, **changes: Any) -> OutputConfig:
28
+ """Create a new instance with updated values."""
29
+ return replace(self, **changes)
30
+
31
+
32
+ def truncate_json(data: Any, *, config: OutputConfig | None = None) -> str:
33
+ config = config or OutputConfig()
34
+ # Convert JSON to string with indentation
35
+ indent = 4
36
+ serialized = json.dumps(data, indent=indent)
37
+ if not config.truncate:
38
+ return serialized
39
+
40
+ # Split string by lines
41
+
42
+ lines = [
43
+ line[: config.max_width - 3] + "..." if len(line) > config.max_width else line
44
+ for line in serialized.split("\n")
45
+ ]
46
+
47
+ if len(lines) <= config.max_lines:
48
+ return "\n".join(lines)
49
+
50
+ truncated_lines = lines[: config.max_lines - 1]
51
+ indentation = " " * indent
52
+ truncated_lines.append(f"{indentation}{TRUNCATED}")
53
+ truncated_lines.append(lines[-1])
54
+
55
+ return "\n".join(truncated_lines)
56
+
57
+
58
+ def prepare_response_payload(payload: str, *, config: OutputConfig | None = None) -> str:
59
+ if payload.endswith("\r\n"):
60
+ payload = payload[:-2]
61
+ elif payload.endswith("\n"):
62
+ payload = payload[:-1]
63
+ config = config or OutputConfig()
64
+ if not config.truncate:
65
+ return payload
66
+ if len(payload) > config.max_payload_size:
67
+ payload = payload[: config.max_payload_size] + f" {TRUNCATED}"
68
+ return payload
@@ -1,4 +1,4 @@
1
- from typing import TypeVar, Generic, Union
1
+ from typing import Generic, TypeVar, Union
2
2
 
3
3
  T = TypeVar("T")
4
4
  E = TypeVar("E", bound=Exception)
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
+
2
3
  from typing import Any
3
4
 
5
+ from ..constants import FALSE_VALUES, TRUE_VALUES
6
+
4
7
 
5
8
  def merge_recursively(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
6
9
  """Merge two dictionaries recursively."""
@@ -13,3 +16,11 @@ def merge_recursively(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
13
16
  else:
14
17
  a[key] = b[key]
15
18
  return a
19
+
20
+
21
+ def convert_boolean_string(value: str) -> str | bool:
22
+ if value.lower() in TRUE_VALUES:
23
+ return True
24
+ if value.lower() in FALSE_VALUES:
25
+ return False
26
+ return value