schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__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 (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1766
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{cli → engine/phases}/probes.py +63 -70
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +153 -39
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +483 -367
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -55
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -765
  156. schemathesis/cli/output/short.py +0 -40
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1231
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -555
  184. schemathesis/runner/events.py +0 -309
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -986
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -315
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -184
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.5.dist-info/METADATA +0 -356
  219. schemathesis-3.25.5.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,19 @@
1
1
  """Expression nodes description and evaluation logic."""
2
+
2
3
  from __future__ import annotations
4
+
3
5
  from dataclasses import dataclass
4
6
  from enum import Enum, unique
5
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any, cast
6
8
 
7
9
  from requests.structures import CaseInsensitiveDict
8
10
 
9
- from .. import references
10
- from .context import ExpressionContext
11
+ from schemathesis.core.transforms import UNRESOLVABLE, resolve_pointer
12
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
13
+
14
+ if TYPE_CHECKING:
15
+ from .context import ExpressionContext
16
+ from .extractors import Extractor
11
17
 
12
18
 
13
19
  @dataclass
@@ -48,7 +54,12 @@ class URL(Node):
48
54
  """A node for `$url` expression."""
49
55
 
50
56
  def evaluate(self, context: ExpressionContext) -> str:
51
- return context.case.get_full_url()
57
+ import requests
58
+
59
+ base_url = context.case.operation.base_url or "http://127.0.0.1"
60
+ kwargs = REQUESTS_TRANSPORT.serialize_case(context.case, base_url=base_url)
61
+ prepared = requests.Request(**kwargs).prepare()
62
+ return cast(str, prepared.url)
52
63
 
53
64
 
54
65
  @dataclass
@@ -73,6 +84,7 @@ class NonBodyRequest(Node):
73
84
 
74
85
  location: str
75
86
  parameter: str
87
+ extractor: Extractor | None = None
76
88
 
77
89
  def evaluate(self, context: ExpressionContext) -> str:
78
90
  container: dict | CaseInsensitiveDict = {
@@ -82,7 +94,12 @@ class NonBodyRequest(Node):
82
94
  }[self.location] or {}
83
95
  if self.location == "header":
84
96
  container = CaseInsensitiveDict(container)
85
- return container[self.parameter]
97
+ value = container.get(self.parameter)
98
+ if value is None:
99
+ return ""
100
+ if self.extractor is not None:
101
+ return self.extractor.extract(value) or ""
102
+ return value
86
103
 
87
104
 
88
105
  @dataclass
@@ -95,7 +112,10 @@ class BodyRequest(Node):
95
112
  document = context.case.body
96
113
  if self.pointer is None:
97
114
  return document
98
- return references.resolve_pointer(document, self.pointer[1:])
115
+ resolved = resolve_pointer(document, self.pointer[1:])
116
+ if resolved is UNRESOLVABLE:
117
+ return None
118
+ return resolved
99
119
 
100
120
 
101
121
  @dataclass
@@ -103,9 +123,15 @@ class HeaderResponse(Node):
103
123
  """A node for `$response.header` expressions."""
104
124
 
105
125
  parameter: str
126
+ extractor: Extractor | None = None
106
127
 
107
128
  def evaluate(self, context: ExpressionContext) -> str:
108
- return context.response.headers[self.parameter]
129
+ value = context.response.headers.get(self.parameter.lower())
130
+ if value is None:
131
+ return ""
132
+ if self.extractor is not None:
133
+ return self.extractor.extract(value[0]) or ""
134
+ return value[0]
109
135
 
110
136
 
111
137
  @dataclass
@@ -115,13 +141,11 @@ class BodyResponse(Node):
115
141
  pointer: str | None = None
116
142
 
117
143
  def evaluate(self, context: ExpressionContext) -> Any:
118
- from ....transports.responses import WSGIResponse
119
-
120
- if isinstance(context.response, WSGIResponse):
121
- document = context.response.json
122
- else:
123
- document = context.response.json()
144
+ document = context.response.json()
124
145
  if self.pointer is None:
125
146
  # We need the parsed document - data will be serialized before sending to the application
126
147
  return document
127
- return references.resolve_pointer(document, self.pointer[1:])
148
+ resolved = resolve_pointer(document, self.pointer[1:])
149
+ if resolved is UNRESOLVABLE:
150
+ return None
151
+ return resolved
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
+
3
+ import re
2
4
  from functools import lru_cache
3
5
  from typing import Generator
4
6
 
5
- from . import lexer, nodes
7
+ from . import extractors, lexer, nodes
6
8
  from .errors import RuntimeExpressionError, UnknownToken
7
9
 
8
10
 
@@ -53,7 +55,8 @@ def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> nodes.BodyRequest
53
55
  if location.value in ("query", "path", "header"):
54
56
  skip_dot(tokens, f"$request.{location.value}")
55
57
  parameter = take_string(tokens, expr)
56
- return nodes.NonBodyRequest(location.value, parameter)
58
+ extractor = take_extractor(tokens, expr, parameter.end)
59
+ return nodes.NonBodyRequest(location.value, parameter.value, extractor)
57
60
  if location.value == "body":
58
61
  try:
59
62
  token = next(tokens)
@@ -70,7 +73,8 @@ def _parse_response(tokens: lexer.TokenGenerator, expr: str) -> nodes.HeaderResp
70
73
  if location.value == "header":
71
74
  skip_dot(tokens, f"$response.{location.value}")
72
75
  parameter = take_string(tokens, expr)
73
- return nodes.HeaderResponse(parameter)
76
+ extractor = take_extractor(tokens, expr, parameter.end)
77
+ return nodes.HeaderResponse(parameter.value, extractor=extractor)
74
78
  if location.value == "body":
75
79
  try:
76
80
  token = next(tokens)
@@ -87,8 +91,25 @@ def skip_dot(tokens: lexer.TokenGenerator, name: str) -> None:
87
91
  raise RuntimeExpressionError(f"`{name}` expression should be followed by a dot (`.`). Got: {token.value}")
88
92
 
89
93
 
90
- def take_string(tokens: lexer.TokenGenerator, expr: str) -> str:
94
+ def take_string(tokens: lexer.TokenGenerator, expr: str) -> lexer.Token:
91
95
  parameter = next(tokens)
92
96
  if not parameter.is_string:
93
97
  raise RuntimeExpressionError(f"Invalid expression: {expr}")
94
- return parameter.value
98
+ return parameter
99
+
100
+
101
+ def take_extractor(tokens: lexer.TokenGenerator, expr: str, current_end: int) -> extractors.Extractor | None:
102
+ rest = expr[current_end + 1 :]
103
+ if not rest or rest.startswith("}"):
104
+ return None
105
+ extractor = next(tokens)
106
+ if not extractor.value.startswith("#regex:"):
107
+ raise RuntimeExpressionError(f"Invalid extractor: {expr}")
108
+ pattern = extractor.value[len("#regex:") :]
109
+ try:
110
+ compiled = re.compile(pattern)
111
+ except re.error as exc:
112
+ raise RuntimeExpressionError(f"Invalid regex extractor: {exc}") from None
113
+ if compiled.groups != 1:
114
+ raise RuntimeExpressionError("Regex extractor should have exactly one capturing group")
115
+ return extractors.RegexExtractor(compiled)
@@ -1,7 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import string
4
+ from base64 import b64encode
5
+ from functools import lru_cache
3
6
  from typing import TYPE_CHECKING
4
7
 
8
+ from schemathesis.transport.serialization import Binary
9
+
5
10
  if TYPE_CHECKING:
6
11
  from hypothesis import strategies as st
7
12
 
@@ -33,5 +38,45 @@ def unregister_string_format(name: str) -> None:
33
38
  raise ValueError(f"Unknown Open API format: {name}") from exc
34
39
 
35
40
 
41
+ def header_values(blacklist_characters: str = "\n\r") -> st.SearchStrategy[str]:
42
+ from hypothesis import strategies as st
43
+
44
+ return st.text(
45
+ alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters=blacklist_characters)
46
+ # Header values with leading non-visible chars can't be sent with `requests`
47
+ ).map(str.lstrip)
48
+
49
+
50
+ HEADER_FORMAT = "_header_value"
51
+
52
+
53
+ @lru_cache
54
+ def get_default_format_strategies() -> dict[str, st.SearchStrategy]:
55
+ """Get all default "format" strategies."""
56
+ from hypothesis import strategies as st
57
+ from requests.auth import _basic_auth_str
58
+
59
+ def make_basic_auth_str(item: tuple[str, str]) -> str:
60
+ return _basic_auth_str(*item)
61
+
62
+ latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
63
+
64
+ # Define valid characters here to avoid filtering them out in `is_valid_header` later
65
+ header_value = header_values()
66
+
67
+ return {
68
+ "binary": st.binary().map(Binary),
69
+ "byte": st.binary().map(lambda x: b64encode(x).decode()),
70
+ "uuid": st.uuids().map(str),
71
+ # RFC 7230, Section 3.2.6
72
+ "_header_name": st.text(
73
+ min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)
74
+ ),
75
+ HEADER_FORMAT: header_value,
76
+ "_basic_auth": st.tuples(latin1_text, latin1_text).map(make_basic_auth_str),
77
+ "_bearer_auth": header_value.map("Bearer {}".format),
78
+ }
79
+
80
+
36
81
  register = register_string_format
37
82
  unregister = unregister_string_format
@@ -2,165 +2,31 @@
2
2
 
3
3
  Based on https://swagger.io/docs/specification/links/
4
4
  """
5
+
5
6
  from __future__ import annotations
7
+
6
8
  from dataclasses import dataclass, field
7
9
  from difflib import get_close_matches
8
- from typing import Any, Generator, NoReturn, Sequence, Union, TYPE_CHECKING
10
+ from typing import TYPE_CHECKING, Any, Generator, Literal, TypedDict, Union, cast
9
11
 
10
- from ...models import APIOperation, Case
11
- from ...parameters import ParameterSet
12
- from ...stateful import ParsedData, StatefulTest
13
- from ...stateful.state_machine import Direction
14
- from ...types import NotSet
12
+ from schemathesis.core import NOT_SET, NotSet
13
+ from schemathesis.generation.case import Case
14
+ from schemathesis.generation.stateful.state_machine import Direction
15
+ from schemathesis.schemas import APIOperation
15
16
 
16
- from ...constants import NOT_SET
17
- from ...internal.copy import fast_deepcopy
18
17
  from . import expressions
19
18
  from .constants import LOCATION_TO_CONTAINER
20
- from .parameters import OpenAPI20Body, OpenAPI30Body, OpenAPIParameter
21
-
19
+ from .references import RECURSION_DEPTH_LIMIT
22
20
 
23
21
  if TYPE_CHECKING:
24
- from ...transports.responses import GenericResponse
22
+ from jsonschema import RefResolver
25
23
 
26
24
 
27
- @dataclass(repr=False)
28
- class Link(StatefulTest):
29
- operation: APIOperation
30
- parameters: dict[str, Any]
31
- request_body: Any = NOT_SET
25
+ SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
32
26
 
33
- def __post_init__(self) -> None:
34
- if self.request_body is not NOT_SET and not self.operation.body:
35
- # Link defines `requestBody` for a parameter that does not accept one
36
- raise ValueError(
37
- f"Request body is not defined in API operation {self.operation.method.upper()} {self.operation.path}"
38
- )
39
-
40
- @classmethod
41
- def from_definition(cls, name: str, definition: dict[str, dict[str, Any]], source_operation: APIOperation) -> Link:
42
- # Links can be behind a reference
43
- _, definition = source_operation.schema.resolver.resolve_in_scope( # type: ignore
44
- definition, source_operation.definition.scope
45
- )
46
- if "operationId" in definition:
47
- # source_operation.schema is `BaseOpenAPISchema` and has this method
48
- operation = source_operation.schema.get_operation_by_id(definition["operationId"]) # type: ignore
49
- else:
50
- operation = source_operation.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
51
- return cls(
52
- # Pylint can't detect that the API operation is always defined at this point
53
- # E.g. if there is no matching operation or no operations at all, then a ValueError will be risen
54
- name=name,
55
- operation=operation,
56
- parameters=definition.get("parameters", {}),
57
- request_body=definition.get("requestBody", NOT_SET), # `None` might be a valid value - `null`
58
- )
59
-
60
- def parse(self, case: Case, response: GenericResponse) -> ParsedData:
61
- """Parse data into a structure expected by links definition."""
62
- context = expressions.ExpressionContext(case=case, response=response)
63
- parameters = {
64
- parameter: expressions.evaluate(expression, context) for parameter, expression in self.parameters.items()
65
- }
66
- return ParsedData(
67
- parameters=parameters,
68
- # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#link-object
69
- # > A literal value or {expression} to use as a request body when calling the target operation.
70
- # In this case all literals will be passed as is, and expressions will be evaluated
71
- body=expressions.evaluate(self.request_body, context),
72
- )
73
-
74
- def make_operation(self, collected: list[ParsedData]) -> APIOperation:
75
- """Create a modified version of the original API operation with additional data merged in."""
76
- # We split the gathered data among all locations & store the original parameter
77
- containers = {
78
- location: {
79
- parameter.name: {"options": [], "parameter": parameter}
80
- for parameter in getattr(self.operation, container_name)
81
- }
82
- for location, container_name in LOCATION_TO_CONTAINER.items()
83
- }
84
- # There might be duplicates in the data
85
- for item in set(collected):
86
- for name, value in item.parameters.items():
87
- container = self._get_container_by_parameter_name(name, containers)
88
- container.append(value)
89
- if "body" in containers["body"] and item.body is not NOT_SET:
90
- containers["body"]["body"]["options"].append(item.body)
91
- # These are the final `path_parameters`, `query`, and other API operation components
92
- components: dict[str, ParameterSet] = {
93
- container_name: getattr(self.operation, container_name).__class__()
94
- for location, container_name in LOCATION_TO_CONTAINER.items()
95
- }
96
- # Here are all components that are filled with parameters
97
- for location, parameters in containers.items():
98
- for parameter_data in parameters.values():
99
- parameter = parameter_data["parameter"]
100
- if parameter_data["options"]:
101
- definition = fast_deepcopy(parameter.definition)
102
- if "schema" in definition:
103
- # The actual schema doesn't matter since we have a list of allowed values
104
- definition["schema"] = {"enum": parameter_data["options"]}
105
- else:
106
- # Other schema-related keywords will be ignored later, during the canonicalisation step
107
- # inside `hypothesis-jsonschema`
108
- definition["enum"] = parameter_data["options"]
109
- new_parameter: OpenAPIParameter
110
- if isinstance(parameter, OpenAPI30Body):
111
- new_parameter = parameter.__class__(
112
- definition, media_type=parameter.media_type, required=parameter.required
113
- )
114
- elif isinstance(parameter, OpenAPI20Body):
115
- new_parameter = parameter.__class__(definition, media_type=parameter.media_type)
116
- else:
117
- new_parameter = parameter.__class__(definition)
118
- components[LOCATION_TO_CONTAINER[location]].add(new_parameter)
119
- else:
120
- # No options were gathered for this parameter - use the original one
121
- components[LOCATION_TO_CONTAINER[location]].add(parameter)
122
- return self.operation.clone(**components)
123
-
124
- def _get_container_by_parameter_name(self, full_name: str, templates: dict[str, dict[str, dict[str, Any]]]) -> list:
125
- """Detect in what request part the parameters is defined."""
126
- location: str | None
127
- try:
128
- # The parameter name is prefixed with its location. Example: `path.id`
129
- location, name = full_name.split(".")
130
- except ValueError:
131
- location, name = None, full_name
132
- if location:
133
- try:
134
- parameters = templates[location]
135
- except KeyError:
136
- self._unknown_parameter(full_name)
137
- else:
138
- for parameters in templates.values():
139
- if name in parameters:
140
- break
141
- else:
142
- self._unknown_parameter(full_name)
143
- if not parameters:
144
- self._unknown_parameter(full_name)
145
- return parameters[name]["options"]
146
-
147
- def _unknown_parameter(self, name: str) -> NoReturn:
148
- raise ValueError(
149
- f"Parameter `{name}` is not defined in API operation {self.operation.method.upper()} {self.operation.path}"
150
- )
151
-
152
-
153
- def get_links(response: GenericResponse, operation: APIOperation, field: str) -> Sequence[Link]:
154
- """Get `x-links` / `links` definitions from the schema."""
155
- responses = operation.definition.resolved["responses"]
156
- if str(response.status_code) in responses:
157
- response_definition = responses[str(response.status_code)]
158
- elif response.status_code in responses:
159
- response_definition = responses[response.status_code]
160
- else:
161
- response_definition = responses.get("default", {})
162
- links = response_definition.get(field, {})
163
- return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
27
+
28
+ class SchemathesisLink(TypedDict):
29
+ merge_body: bool
164
30
 
165
31
 
166
32
  @dataclass(repr=False)
@@ -174,41 +40,59 @@ class OpenAPILink(Direction):
174
40
  status_code: str
175
41
  definition: dict[str, Any]
176
42
  operation: APIOperation
177
- parameters: list[tuple[str | None, str, str]] = field(init=False)
43
+ parameters: list[tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]] = field(init=False)
178
44
  body: dict[str, Any] | NotSet = field(init=False)
45
+ merge_body: bool = True
46
+
47
+ def __repr__(self) -> str:
48
+ path = self.operation.path
49
+ method = self.operation.method
50
+ return f"state.schema['{path}']['{method}'].links['{self.status_code}']['{self.name}']"
179
51
 
180
52
  def __post_init__(self) -> None:
53
+ extension = self.definition.get(SCHEMATHESIS_LINK_EXTENSION)
181
54
  self.parameters = [
182
55
  normalize_parameter(parameter, expression)
183
56
  for parameter, expression in self.definition.get("parameters", {}).items()
184
57
  ]
185
58
  self.body = self.definition.get("requestBody", NOT_SET)
59
+ if extension is not None:
60
+ self.merge_body = extension.get("merge_body", True)
186
61
 
187
- def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
62
+ def set_data(self, case: Case, **kwargs: Any) -> None:
188
63
  """Assign all linked definitions to the new case instance."""
189
64
  context = kwargs["context"]
190
65
  self.set_parameters(case, context)
191
66
  self.set_body(case, context)
192
- case.set_source(context.response, context.case, elapsed)
193
67
 
194
68
  def set_parameters(self, case: Case, context: expressions.ExpressionContext) -> None:
195
69
  for location, name, expression in self.parameters:
196
- container = get_container(case, location, name)
70
+ location, container = get_container(case, location, name)
197
71
  # Might happen if there is directly specified container,
198
72
  # but the schema has no parameters of such type at all.
199
73
  # Therefore the container is empty, otherwise it will be at least an empty object
200
74
  if container is None:
201
75
  message = f"No such parameter in `{case.operation.method.upper()} {case.operation.path}`: `{name}`."
202
- possibilities = [param.name for param in case.operation.definition.parameters]
76
+ possibilities = [param.name for param in case.operation.iter_parameters()]
203
77
  matches = get_close_matches(name, possibilities)
204
78
  if matches:
205
79
  message += f" Did you mean `{matches[0]}`?"
206
80
  raise ValueError(message)
207
- container[name] = expressions.evaluate(expression, context)
208
-
209
- def set_body(self, case: Case, context: expressions.ExpressionContext) -> None:
81
+ value = expressions.evaluate(expression, context)
82
+ if value is not None:
83
+ container[name] = value
84
+
85
+ def set_body(
86
+ self,
87
+ case: Case,
88
+ context: expressions.ExpressionContext,
89
+ ) -> None:
210
90
  if self.body is not NOT_SET:
211
- case.body = expressions.evaluate(self.body, context)
91
+ evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
92
+ if self.merge_body:
93
+ case.body = merge_body(case.body, evaluated)
94
+ else:
95
+ case.body = evaluated
212
96
 
213
97
  def get_target_operation(self) -> APIOperation:
214
98
  if "operationId" in self.definition:
@@ -216,21 +100,32 @@ class OpenAPILink(Direction):
216
100
  return self.operation.schema.get_operation_by_reference(self.definition["operationRef"]) # type: ignore
217
101
 
218
102
 
219
- def get_container(case: Case, location: str | None, name: str) -> dict[str, Any] | None:
103
+ def merge_body(old: Any, new: Any) -> Any:
104
+ if isinstance(old, dict) and isinstance(new, dict):
105
+ return {**old, **new}
106
+ return new
107
+
108
+
109
+ def get_container(
110
+ case: Case, location: Literal["path", "query", "header", "cookie", "body"] | None, name: str
111
+ ) -> tuple[Literal["path", "query", "header", "cookie", "body"], dict[str, Any] | None]:
220
112
  """Get a container that suppose to store the given parameter."""
221
113
  if location:
222
114
  container_name = LOCATION_TO_CONTAINER[location]
223
115
  else:
224
- for param in case.operation.definition.parameters:
116
+ for param in case.operation.iter_parameters():
225
117
  if param.name == name:
118
+ location = param.location
226
119
  container_name = LOCATION_TO_CONTAINER[param.location]
227
120
  break
228
121
  else:
229
- raise ValueError(f"Parameter `{name}` is not defined in API operation `{case.operation.verbose_name}`")
230
- return getattr(case, container_name)
122
+ raise ValueError(f"Parameter `{name}` is not defined in API operation `{case.operation.label}`")
123
+ return location, getattr(case, container_name)
231
124
 
232
125
 
233
- def normalize_parameter(parameter: str, expression: str) -> tuple[str | None, str, str]:
126
+ def normalize_parameter(
127
+ parameter: str, expression: str
128
+ ) -> tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]:
234
129
  """Normalize runtime expressions.
235
130
 
236
131
  Runtime expressions may have parameter names prefixed with their location - `path.id`.
@@ -240,13 +135,15 @@ def normalize_parameter(parameter: str, expression: str) -> tuple[str | None, st
240
135
  try:
241
136
  # The parameter name is prefixed with its location. Example: `path.id`
242
137
  location, name = tuple(parameter.split("."))
243
- return location, name, expression
138
+ _location = cast(Literal["path", "query", "header", "cookie", "body"], location)
139
+ return _location, name, expression
244
140
  except ValueError:
245
141
  return None, parameter, expression
246
142
 
247
143
 
248
144
  def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenAPILink], None, None]:
249
- for status_code, definition in operation.definition.resolved["responses"].items():
145
+ for status_code, definition in operation.definition.raw["responses"].items():
146
+ definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
250
147
  for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
251
148
  yield status_code, OpenAPILink(name, status_code, link_definition, operation)
252
149
 
@@ -273,6 +170,7 @@ def _get_response_by_status_code(responses: dict[StatusCode, dict[str, Any]], st
273
170
 
274
171
 
275
172
  def add_link(
173
+ resolver: RefResolver,
276
174
  responses: dict[StatusCode, dict[str, Any]],
277
175
  links_field: str,
278
176
  parameters: dict[str, str] | None,
@@ -282,6 +180,8 @@ def add_link(
282
180
  name: str | None = None,
283
181
  ) -> None:
284
182
  response = _get_response_by_status_code(responses, status_code)
183
+ if "$ref" in response:
184
+ _, response = resolver.resolve(response["$ref"])
285
185
  links_definition = response.setdefault(links_field, {})
286
186
  new_link: dict[str, str | dict[str, str]] = {}
287
187
  if parameters is not None:
@@ -295,8 +195,8 @@ def add_link(
295
195
  name = name or f"{target.method.upper()} {target.path}"
296
196
  # operationId is a dict lookup which is more efficient than using `operationRef`, since it
297
197
  # doesn't involve reference resolving when we will look up for this target during testing.
298
- if "operationId" in target.definition.resolved:
299
- new_link["operationId"] = target.definition.resolved["operationId"]
198
+ if "operationId" in target.definition.raw:
199
+ new_link["operationId"] = target.definition.raw["operationId"]
300
200
  else:
301
201
  new_link["operationRef"] = target.operation_reference
302
202
  # The name is arbitrary, so we don't really case what it is,
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Collection
4
+
5
+ from schemathesis.transport import SerializationContext
6
+ from schemathesis.transport.asgi import ASGI_TRANSPORT
7
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
8
+ from schemathesis.transport.wsgi import WSGI_TRANSPORT
9
+
10
+ if TYPE_CHECKING:
11
+ from hypothesis import strategies as st
12
+
13
+
14
+ MEDIA_TYPES: dict[str, st.SearchStrategy[bytes]] = {}
15
+
16
+
17
+ def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliases: Collection[str] = ()) -> None:
18
+ """Register a strategy for the given media type."""
19
+
20
+ @REQUESTS_TRANSPORT.serializer(name, *aliases)
21
+ @ASGI_TRANSPORT.serializer(name, *aliases)
22
+ @WSGI_TRANSPORT.serializer(name, *aliases)
23
+ def serialize(ctx: SerializationContext, value: Any) -> dict[str, Any]:
24
+ return {"data": value}
25
+
26
+ MEDIA_TYPES[name] = strategy
27
+ for alias in aliases:
28
+ MEDIA_TYPES[alias] = strategy
29
+
30
+
31
+ def unregister_all() -> None:
32
+ MEDIA_TYPES.clear()
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
+
2
3
  from dataclasses import dataclass
3
4
  from functools import lru_cache
4
- from typing import Any
5
+ from typing import TYPE_CHECKING, Any
5
6
  from urllib.parse import urlencode
6
7
 
7
8
  import jsonschema
@@ -10,8 +11,11 @@ from hypothesis_jsonschema import from_schema
10
11
 
11
12
  from ..constants import ALL_KEYWORDS
12
13
  from .mutations import MutationContext
13
- from .types import Draw, Schema
14
- from ....generation import GenerationConfig
14
+
15
+ if TYPE_CHECKING:
16
+ from schemathesis.generation import GenerationConfig
17
+
18
+ from .types import Draw, Schema
15
19
 
16
20
 
17
21
  @dataclass