schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 (251) hide show
  1. schemathesis/__init__.py +53 -25
  2. schemathesis/auths.py +507 -0
  3. schemathesis/checks.py +190 -25
  4. schemathesis/cli/__init__.py +27 -1219
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +133 -0
  7. schemathesis/cli/commands/data.py +10 -0
  8. schemathesis/cli/commands/run/__init__.py +602 -0
  9. schemathesis/cli/commands/run/context.py +228 -0
  10. schemathesis/cli/commands/run/events.py +60 -0
  11. schemathesis/cli/commands/run/executor.py +157 -0
  12. schemathesis/cli/commands/run/filters.py +53 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +45 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
  17. schemathesis/cli/commands/run/handlers/output.py +1750 -0
  18. schemathesis/cli/commands/run/loaders.py +118 -0
  19. schemathesis/cli/commands/run/validation.py +256 -0
  20. schemathesis/cli/constants.py +5 -0
  21. schemathesis/cli/core.py +19 -0
  22. schemathesis/cli/ext/fs.py +16 -0
  23. schemathesis/cli/ext/groups.py +203 -0
  24. schemathesis/cli/ext/options.py +81 -0
  25. schemathesis/config/__init__.py +202 -0
  26. schemathesis/config/_auth.py +51 -0
  27. schemathesis/config/_checks.py +268 -0
  28. schemathesis/config/_diff_base.py +101 -0
  29. schemathesis/config/_env.py +21 -0
  30. schemathesis/config/_error.py +163 -0
  31. schemathesis/config/_generation.py +157 -0
  32. schemathesis/config/_health_check.py +24 -0
  33. schemathesis/config/_operations.py +335 -0
  34. schemathesis/config/_output.py +171 -0
  35. schemathesis/config/_parameters.py +19 -0
  36. schemathesis/config/_phases.py +253 -0
  37. schemathesis/config/_projects.py +543 -0
  38. schemathesis/config/_rate_limit.py +17 -0
  39. schemathesis/config/_report.py +120 -0
  40. schemathesis/config/_validator.py +9 -0
  41. schemathesis/config/_warnings.py +89 -0
  42. schemathesis/config/schema.json +975 -0
  43. schemathesis/core/__init__.py +72 -0
  44. schemathesis/core/adapter.py +34 -0
  45. schemathesis/core/compat.py +32 -0
  46. schemathesis/core/control.py +2 -0
  47. schemathesis/core/curl.py +100 -0
  48. schemathesis/core/deserialization.py +210 -0
  49. schemathesis/core/errors.py +588 -0
  50. schemathesis/core/failures.py +316 -0
  51. schemathesis/core/fs.py +19 -0
  52. schemathesis/core/hooks.py +20 -0
  53. schemathesis/core/jsonschema/__init__.py +13 -0
  54. schemathesis/core/jsonschema/bundler.py +183 -0
  55. schemathesis/core/jsonschema/keywords.py +40 -0
  56. schemathesis/core/jsonschema/references.py +222 -0
  57. schemathesis/core/jsonschema/types.py +41 -0
  58. schemathesis/core/lazy_import.py +15 -0
  59. schemathesis/core/loaders.py +107 -0
  60. schemathesis/core/marks.py +66 -0
  61. schemathesis/core/media_types.py +79 -0
  62. schemathesis/core/output/__init__.py +46 -0
  63. schemathesis/core/output/sanitization.py +54 -0
  64. schemathesis/core/parameters.py +45 -0
  65. schemathesis/core/rate_limit.py +60 -0
  66. schemathesis/core/registries.py +34 -0
  67. schemathesis/core/result.py +27 -0
  68. schemathesis/core/schema_analysis.py +17 -0
  69. schemathesis/core/shell.py +203 -0
  70. schemathesis/core/transforms.py +144 -0
  71. schemathesis/core/transport.py +223 -0
  72. schemathesis/core/validation.py +73 -0
  73. schemathesis/core/version.py +7 -0
  74. schemathesis/engine/__init__.py +28 -0
  75. schemathesis/engine/context.py +152 -0
  76. schemathesis/engine/control.py +44 -0
  77. schemathesis/engine/core.py +201 -0
  78. schemathesis/engine/errors.py +446 -0
  79. schemathesis/engine/events.py +284 -0
  80. schemathesis/engine/observations.py +42 -0
  81. schemathesis/engine/phases/__init__.py +108 -0
  82. schemathesis/engine/phases/analysis.py +28 -0
  83. schemathesis/engine/phases/probes.py +172 -0
  84. schemathesis/engine/phases/stateful/__init__.py +68 -0
  85. schemathesis/engine/phases/stateful/_executor.py +364 -0
  86. schemathesis/engine/phases/stateful/context.py +85 -0
  87. schemathesis/engine/phases/unit/__init__.py +220 -0
  88. schemathesis/engine/phases/unit/_executor.py +459 -0
  89. schemathesis/engine/phases/unit/_pool.py +82 -0
  90. schemathesis/engine/recorder.py +254 -0
  91. schemathesis/errors.py +47 -0
  92. schemathesis/filters.py +395 -0
  93. schemathesis/generation/__init__.py +25 -0
  94. schemathesis/generation/case.py +478 -0
  95. schemathesis/generation/coverage.py +1528 -0
  96. schemathesis/generation/hypothesis/__init__.py +121 -0
  97. schemathesis/generation/hypothesis/builder.py +992 -0
  98. schemathesis/generation/hypothesis/examples.py +56 -0
  99. schemathesis/generation/hypothesis/given.py +66 -0
  100. schemathesis/generation/hypothesis/reporting.py +285 -0
  101. schemathesis/generation/meta.py +227 -0
  102. schemathesis/generation/metrics.py +93 -0
  103. schemathesis/generation/modes.py +20 -0
  104. schemathesis/generation/overrides.py +127 -0
  105. schemathesis/generation/stateful/__init__.py +37 -0
  106. schemathesis/generation/stateful/state_machine.py +294 -0
  107. schemathesis/graphql/__init__.py +15 -0
  108. schemathesis/graphql/checks.py +109 -0
  109. schemathesis/graphql/loaders.py +285 -0
  110. schemathesis/hooks.py +270 -91
  111. schemathesis/openapi/__init__.py +13 -0
  112. schemathesis/openapi/checks.py +467 -0
  113. schemathesis/openapi/generation/__init__.py +0 -0
  114. schemathesis/openapi/generation/filters.py +72 -0
  115. schemathesis/openapi/loaders.py +315 -0
  116. schemathesis/pytest/__init__.py +5 -0
  117. schemathesis/pytest/control_flow.py +7 -0
  118. schemathesis/pytest/lazy.py +341 -0
  119. schemathesis/pytest/loaders.py +36 -0
  120. schemathesis/pytest/plugin.py +357 -0
  121. schemathesis/python/__init__.py +0 -0
  122. schemathesis/python/asgi.py +12 -0
  123. schemathesis/python/wsgi.py +12 -0
  124. schemathesis/schemas.py +682 -257
  125. schemathesis/specs/graphql/__init__.py +0 -1
  126. schemathesis/specs/graphql/nodes.py +26 -2
  127. schemathesis/specs/graphql/scalars.py +77 -12
  128. schemathesis/specs/graphql/schemas.py +367 -148
  129. schemathesis/specs/graphql/validation.py +33 -0
  130. schemathesis/specs/openapi/__init__.py +9 -1
  131. schemathesis/specs/openapi/_hypothesis.py +555 -318
  132. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  133. schemathesis/specs/openapi/adapter/parameters.py +729 -0
  134. schemathesis/specs/openapi/adapter/protocol.py +59 -0
  135. schemathesis/specs/openapi/adapter/references.py +19 -0
  136. schemathesis/specs/openapi/adapter/responses.py +368 -0
  137. schemathesis/specs/openapi/adapter/security.py +144 -0
  138. schemathesis/specs/openapi/adapter/v2.py +30 -0
  139. schemathesis/specs/openapi/adapter/v3_0.py +30 -0
  140. schemathesis/specs/openapi/adapter/v3_1.py +30 -0
  141. schemathesis/specs/openapi/analysis.py +96 -0
  142. schemathesis/specs/openapi/checks.py +748 -82
  143. schemathesis/specs/openapi/converter.py +176 -37
  144. schemathesis/specs/openapi/definitions.py +599 -4
  145. schemathesis/specs/openapi/examples.py +581 -165
  146. schemathesis/specs/openapi/expressions/__init__.py +52 -5
  147. schemathesis/specs/openapi/expressions/extractors.py +25 -0
  148. schemathesis/specs/openapi/expressions/lexer.py +34 -31
  149. schemathesis/specs/openapi/expressions/nodes.py +97 -46
  150. schemathesis/specs/openapi/expressions/parser.py +35 -13
  151. schemathesis/specs/openapi/formats.py +122 -0
  152. schemathesis/specs/openapi/media_types.py +75 -0
  153. schemathesis/specs/openapi/negative/__init__.py +93 -73
  154. schemathesis/specs/openapi/negative/mutations.py +294 -103
  155. schemathesis/specs/openapi/negative/utils.py +0 -9
  156. schemathesis/specs/openapi/patterns.py +458 -0
  157. schemathesis/specs/openapi/references.py +60 -81
  158. schemathesis/specs/openapi/schemas.py +647 -666
  159. schemathesis/specs/openapi/serialization.py +53 -30
  160. schemathesis/specs/openapi/stateful/__init__.py +403 -68
  161. schemathesis/specs/openapi/stateful/control.py +87 -0
  162. schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
  163. schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
  164. schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
  165. schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
  166. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  167. schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
  168. schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
  169. schemathesis/specs/openapi/stateful/inference.py +254 -0
  170. schemathesis/specs/openapi/stateful/links.py +219 -78
  171. schemathesis/specs/openapi/types/__init__.py +3 -0
  172. schemathesis/specs/openapi/types/common.py +23 -0
  173. schemathesis/specs/openapi/types/v2.py +129 -0
  174. schemathesis/specs/openapi/types/v3.py +134 -0
  175. schemathesis/specs/openapi/utils.py +7 -6
  176. schemathesis/specs/openapi/warnings.py +75 -0
  177. schemathesis/transport/__init__.py +224 -0
  178. schemathesis/transport/asgi.py +26 -0
  179. schemathesis/transport/prepare.py +126 -0
  180. schemathesis/transport/requests.py +278 -0
  181. schemathesis/transport/serialization.py +329 -0
  182. schemathesis/transport/wsgi.py +175 -0
  183. schemathesis-4.4.2.dist-info/METADATA +213 -0
  184. schemathesis-4.4.2.dist-info/RECORD +192 -0
  185. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
  186. schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
  187. {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
  188. schemathesis/_compat.py +0 -57
  189. schemathesis/_hypothesis.py +0 -123
  190. schemathesis/auth.py +0 -214
  191. schemathesis/cli/callbacks.py +0 -240
  192. schemathesis/cli/cassettes.py +0 -351
  193. schemathesis/cli/context.py +0 -38
  194. schemathesis/cli/debug.py +0 -21
  195. schemathesis/cli/handlers.py +0 -11
  196. schemathesis/cli/junitxml.py +0 -41
  197. schemathesis/cli/options.py +0 -70
  198. schemathesis/cli/output/__init__.py +0 -1
  199. schemathesis/cli/output/default.py +0 -521
  200. schemathesis/cli/output/short.py +0 -40
  201. schemathesis/constants.py +0 -88
  202. schemathesis/exceptions.py +0 -257
  203. schemathesis/extra/_aiohttp.py +0 -27
  204. schemathesis/extra/_flask.py +0 -10
  205. schemathesis/extra/_server.py +0 -16
  206. schemathesis/extra/pytest_plugin.py +0 -251
  207. schemathesis/failures.py +0 -145
  208. schemathesis/fixups/__init__.py +0 -29
  209. schemathesis/fixups/fast_api.py +0 -30
  210. schemathesis/graphql.py +0 -5
  211. schemathesis/internal.py +0 -6
  212. schemathesis/lazy.py +0 -301
  213. schemathesis/models.py +0 -1113
  214. schemathesis/parameters.py +0 -91
  215. schemathesis/runner/__init__.py +0 -470
  216. schemathesis/runner/events.py +0 -242
  217. schemathesis/runner/impl/__init__.py +0 -3
  218. schemathesis/runner/impl/core.py +0 -791
  219. schemathesis/runner/impl/solo.py +0 -85
  220. schemathesis/runner/impl/threadpool.py +0 -367
  221. schemathesis/runner/serialization.py +0 -206
  222. schemathesis/serializers.py +0 -253
  223. schemathesis/service/__init__.py +0 -18
  224. schemathesis/service/auth.py +0 -10
  225. schemathesis/service/client.py +0 -62
  226. schemathesis/service/constants.py +0 -25
  227. schemathesis/service/events.py +0 -39
  228. schemathesis/service/handler.py +0 -46
  229. schemathesis/service/hosts.py +0 -74
  230. schemathesis/service/metadata.py +0 -42
  231. schemathesis/service/models.py +0 -21
  232. schemathesis/service/serialization.py +0 -184
  233. schemathesis/service/worker.py +0 -39
  234. schemathesis/specs/graphql/loaders.py +0 -215
  235. schemathesis/specs/openapi/constants.py +0 -7
  236. schemathesis/specs/openapi/expressions/context.py +0 -12
  237. schemathesis/specs/openapi/expressions/pointers.py +0 -29
  238. schemathesis/specs/openapi/filters.py +0 -44
  239. schemathesis/specs/openapi/links.py +0 -303
  240. schemathesis/specs/openapi/loaders.py +0 -453
  241. schemathesis/specs/openapi/parameters.py +0 -430
  242. schemathesis/specs/openapi/security.py +0 -129
  243. schemathesis/specs/openapi/validation.py +0 -24
  244. schemathesis/stateful.py +0 -358
  245. schemathesis/targets.py +0 -32
  246. schemathesis/types.py +0 -38
  247. schemathesis/utils.py +0 -475
  248. schemathesis-3.15.4.dist-info/METADATA +0 -202
  249. schemathesis-3.15.4.dist-info/RECORD +0 -99
  250. schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
  251. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
@@ -2,19 +2,66 @@
2
2
 
3
3
  https://swagger.io/docs/specification/links/#runtime-expressions
4
4
  """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
5
9
  from typing import Any
6
10
 
11
+ from schemathesis.core.transforms import UNRESOLVABLE, Unresolvable
12
+ from schemathesis.generation.stateful.state_machine import StepOutput
13
+
7
14
  from . import lexer, nodes, parser
8
- from .context import ExpressionContext
15
+
16
+ __all__ = ["lexer", "nodes", "parser"]
9
17
 
10
18
 
11
- def evaluate(expr: Any, context: ExpressionContext) -> str:
19
+ def evaluate(expr: Any, output: StepOutput, evaluate_nested: bool = False) -> Any:
12
20
  """Evaluate runtime expression in context."""
21
+ if isinstance(expr, (dict, list)) and evaluate_nested:
22
+ return _evaluate_nested(expr, output)
13
23
  if not isinstance(expr, str):
14
24
  # Can be a non-string constant
15
25
  return expr
16
- parts = [node.evaluate(context) for node in parser.parse(expr)]
26
+ parts = [node.evaluate(output) for node in parser.parse(expr)]
17
27
  if len(parts) == 1:
18
28
  return parts[0] # keep the return type the same as the internal value type
19
- # otherwise, concatenate into a string
20
- return "".join(map(str, parts))
29
+ if any(isinstance(part, Unresolvable) for part in parts):
30
+ return UNRESOLVABLE
31
+ return "".join(str(part) for part in parts if part is not None)
32
+
33
+
34
+ def _evaluate_nested(expr: dict[str, Any] | list, output: StepOutput) -> Any:
35
+ if isinstance(expr, dict):
36
+ result_dict = {}
37
+ for key, value in expr.items():
38
+ new_key = _evaluate_object_key(key, output)
39
+ if new_key is UNRESOLVABLE:
40
+ return new_key
41
+ new_value = evaluate(value, output, evaluate_nested=True)
42
+ if new_value is UNRESOLVABLE:
43
+ return new_value
44
+ result_dict[new_key] = new_value
45
+ return result_dict
46
+ result_list = []
47
+ for item in expr:
48
+ new_value = evaluate(item, output, evaluate_nested=True)
49
+ if new_value is UNRESOLVABLE:
50
+ return new_value
51
+ result_list.append(new_value)
52
+ return result_list
53
+
54
+
55
+ def _evaluate_object_key(key: str, output: StepOutput) -> Any:
56
+ evaluated = evaluate(key, output)
57
+ if evaluated is UNRESOLVABLE:
58
+ return evaluated
59
+ if isinstance(evaluated, str):
60
+ return evaluated
61
+ if isinstance(evaluated, bool):
62
+ return "true" if evaluated else "false"
63
+ if isinstance(evaluated, (int, float)):
64
+ return str(evaluated)
65
+ if evaluated is None:
66
+ return "null"
67
+ return json.dumps(evaluated)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class Extractor:
9
+ def extract(self, value: str) -> str | None:
10
+ raise NotImplementedError
11
+
12
+
13
+ @dataclass
14
+ class RegexExtractor(Extractor):
15
+ """Extract value via a regex."""
16
+
17
+ value: re.Pattern
18
+
19
+ __slots__ = ("value",)
20
+
21
+ def extract(self, value: str) -> str | None:
22
+ match = self.value.search(value)
23
+ if match is None:
24
+ return None
25
+ return match.group(1)
@@ -1,52 +1,55 @@
1
1
  """Lexical analysis of runtime expressions."""
2
+
3
+ from dataclasses import dataclass
2
4
  from enum import Enum, unique
3
5
  from typing import Callable, Generator
4
6
 
5
- import attr
6
-
7
7
 
8
- @unique # pragma: no mutate
9
- class TokenType(Enum):
10
- VARIABLE = 1 # pragma: no mutate
11
- STRING = 2 # pragma: no mutate
12
- POINTER = 3 # pragma: no mutate
13
- DOT = 4 # pragma: no mutate
14
- LBRACKET = 5 # pragma: no mutate
15
- RBRACKET = 6 # pragma: no mutate
8
+ @unique
9
+ class TokenType(int, Enum):
10
+ VARIABLE = 1
11
+ STRING = 2
12
+ POINTER = 3
13
+ DOT = 4
14
+ LBRACKET = 5
15
+ RBRACKET = 6
16
16
 
17
17
 
18
- @attr.s(slots=True) # pragma: no mutate
18
+ @dataclass
19
19
  class Token:
20
20
  """Lexical token that may occur in a runtime expression."""
21
21
 
22
- value: str = attr.ib() # pragma: no mutate
23
- type_: TokenType = attr.ib() # pragma: no mutate
22
+ value: str
23
+ end: int
24
+ type_: TokenType
25
+
26
+ __slots__ = ("value", "end", "type_")
24
27
 
25
28
  # Helpers for cleaner instantiation
26
29
 
27
30
  @classmethod
28
- def variable(cls, value: str) -> "Token":
29
- return cls(value, TokenType.VARIABLE)
31
+ def variable(cls, value: str, end: int) -> "Token":
32
+ return cls(value, end, TokenType.VARIABLE)
30
33
 
31
34
  @classmethod
32
- def string(cls, value: str) -> "Token":
33
- return cls(value, TokenType.STRING)
35
+ def string(cls, value: str, end: int) -> "Token":
36
+ return cls(value, end, TokenType.STRING)
34
37
 
35
38
  @classmethod
36
- def pointer(cls, value: str) -> "Token":
37
- return cls(value, TokenType.POINTER)
39
+ def pointer(cls, value: str, end: int) -> "Token":
40
+ return cls(value, end, TokenType.POINTER)
38
41
 
39
42
  @classmethod
40
- def lbracket(cls) -> "Token":
41
- return cls("{", TokenType.LBRACKET)
43
+ def lbracket(cls, end: int) -> "Token":
44
+ return cls("{", end, TokenType.LBRACKET)
42
45
 
43
46
  @classmethod
44
- def rbracket(cls) -> "Token":
45
- return cls("}", TokenType.RBRACKET)
47
+ def rbracket(cls, end: int) -> "Token":
48
+ return cls("}", end, TokenType.RBRACKET)
46
49
 
47
50
  @classmethod
48
- def dot(cls) -> "Token":
49
- return cls(".", TokenType.DOT)
51
+ def dot(cls, end: int) -> "Token":
52
+ return cls(".", end, TokenType.DOT)
50
53
 
51
54
  # Helpers for simpler type comparison
52
55
 
@@ -103,15 +106,15 @@ def tokenize(expression: str) -> TokenGenerator:
103
106
  if current_symbol() == "$":
104
107
  start = cursor
105
108
  move_until(lambda: is_eol() or current_symbol() in stop_symbols)
106
- yield Token.variable(expression[start:cursor])
109
+ yield Token.variable(expression[start:cursor], cursor - 1)
107
110
  elif current_symbol() == ".":
108
- yield Token.dot()
111
+ yield Token.dot(cursor)
109
112
  move()
110
113
  elif current_symbol() == "{":
111
- yield Token.lbracket()
114
+ yield Token.lbracket(cursor)
112
115
  move()
113
116
  elif current_symbol() == "}":
114
- yield Token.rbracket()
117
+ yield Token.rbracket(cursor)
115
118
  move()
116
119
  elif current_symbol() == "#":
117
120
  start = cursor
@@ -126,8 +129,8 @@ def tokenize(expression: str) -> TokenGenerator:
126
129
  # `ID_{$response.body#/foo}_{$response.body#/bar}`
127
130
  # Which is much easier if we treat `}` as a closing bracket of an embedded runtime expression
128
131
  move_until(lambda: is_eol() or current_symbol() == "}")
129
- yield Token.pointer(expression[start:cursor])
132
+ yield Token.pointer(expression[start:cursor], cursor - 1)
130
133
  else:
131
134
  start = cursor
132
135
  move_until(lambda: is_eol() or current_symbol() in stop_symbols)
133
- yield Token.string(expression[start:cursor])
136
+ yield Token.string(expression[start:cursor], cursor - 1)
@@ -1,25 +1,31 @@
1
1
  """Expression nodes description and evaluation logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
2
6
  from enum import Enum, unique
3
- from typing import Any, Dict, Optional, Union
7
+ from typing import TYPE_CHECKING, Any, cast
4
8
 
5
- import attr
6
9
  from requests.structures import CaseInsensitiveDict
7
10
 
8
- from ....utils import WSGIResponse
9
- from . import pointers
10
- from .context import ExpressionContext
11
+ from schemathesis.core.transforms import UNRESOLVABLE, Unresolvable, resolve_pointer
12
+ from schemathesis.generation.stateful.state_machine import StepOutput
13
+ from schemathesis.transport.requests import REQUESTS_TRANSPORT
11
14
 
15
+ if TYPE_CHECKING:
16
+ from .extractors import Extractor
12
17
 
13
- @attr.s(slots=True) # pragma: no mutate
18
+
19
+ @dataclass
14
20
  class Node:
15
21
  """Generic expression node."""
16
22
 
17
- def evaluate(self, context: ExpressionContext) -> str:
23
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
18
24
  raise NotImplementedError
19
25
 
20
26
 
21
27
  @unique
22
- class NodeType(Enum):
28
+ class NodeType(str, Enum):
23
29
  URL = "$url"
24
30
  METHOD = "$method"
25
31
  STATUS_CODE = "$statusCode"
@@ -27,13 +33,15 @@ class NodeType(Enum):
27
33
  RESPONSE = "$response"
28
34
 
29
35
 
30
- @attr.s(slots=True) # pragma: no mutate
36
+ @dataclass
31
37
  class String(Node):
32
38
  """A simple string that is not evaluated somehow specifically."""
33
39
 
34
- value: str = attr.ib() # pragma: no mutate
40
+ value: str
41
+
42
+ __slots__ = ("value",)
35
43
 
36
- def evaluate(self, context: ExpressionContext) -> str:
44
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
37
45
  """String tokens are passed as they are.
38
46
 
39
47
  ``foo{$request.path.id}``
@@ -43,83 +51,126 @@ class String(Node):
43
51
  return self.value
44
52
 
45
53
 
46
- @attr.s(slots=True) # pragma: no mutate
54
+ @dataclass
47
55
  class URL(Node):
48
56
  """A node for `$url` expression."""
49
57
 
50
- def evaluate(self, context: ExpressionContext) -> str:
51
- return context.case.get_full_url()
58
+ __slots__ = ()
52
59
 
60
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
61
+ import requests
53
62
 
54
- @attr.s(slots=True) # pragma: no mutate
63
+ base_url = output.case.operation.base_url or "http://127.0.0.1"
64
+ kwargs = REQUESTS_TRANSPORT.serialize_case(output.case, base_url=base_url)
65
+ prepared = requests.Request(**kwargs).prepare()
66
+ return cast(str, prepared.url)
67
+
68
+
69
+ @dataclass
55
70
  class Method(Node):
56
71
  """A node for `$method` expression."""
57
72
 
58
- def evaluate(self, context: ExpressionContext) -> str:
59
- return context.case.operation.method.upper()
73
+ __slots__ = ()
60
74
 
75
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
76
+ return output.case.operation.method.upper()
61
77
 
62
- @attr.s(slots=True) # pragma: no mutate
78
+
79
+ @dataclass
63
80
  class StatusCode(Node):
64
81
  """A node for `$statusCode` expression."""
65
82
 
66
- def evaluate(self, context: ExpressionContext) -> str:
67
- return str(context.response.status_code)
83
+ __slots__ = ()
84
+
85
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
86
+ return str(output.response.status_code)
68
87
 
69
88
 
70
- @attr.s(slots=True) # pragma: no mutate
89
+ @dataclass
71
90
  class NonBodyRequest(Node):
72
91
  """A node for `$request` expressions where location is not `body`."""
73
92
 
74
- location: str = attr.ib() # pragma: no mutate
75
- parameter: str = attr.ib() # pragma: no mutate
93
+ location: str
94
+ parameter: str
95
+ extractor: Extractor | None
76
96
 
77
- def evaluate(self, context: ExpressionContext) -> str:
78
- container: Union[Dict, CaseInsensitiveDict] = {
79
- "query": context.case.query,
80
- "path": context.case.path_parameters,
81
- "header": context.case.headers,
97
+ __slots__ = ("location", "parameter", "extractor")
98
+
99
+ def __init__(self, location: str, parameter: str, extractor: Extractor | None = None) -> None:
100
+ self.location = location
101
+ self.parameter = parameter
102
+ self.extractor = extractor
103
+
104
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
105
+ container = {
106
+ "query": output.case.query,
107
+ "path": output.case.path_parameters,
108
+ "header": output.case.headers,
82
109
  }[self.location] or {}
83
110
  if self.location == "header":
84
111
  container = CaseInsensitiveDict(container)
85
- return container[self.parameter]
112
+ value = container.get(self.parameter)
113
+ if value is None:
114
+ return UNRESOLVABLE
115
+ if self.extractor is not None:
116
+ return self.extractor.extract(value) or UNRESOLVABLE
117
+ return value
86
118
 
87
119
 
88
- @attr.s(slots=True) # pragma: no mutate
120
+ @dataclass
89
121
  class BodyRequest(Node):
90
122
  """A node for `$request` expressions where location is `body`."""
91
123
 
92
- pointer: Optional[str] = attr.ib(default=None) # pragma: no mutate
124
+ pointer: str | None
125
+
126
+ __slots__ = ("pointer",)
127
+
128
+ def __init__(self, pointer: str | None = None) -> None:
129
+ self.pointer = pointer
93
130
 
94
- def evaluate(self, context: ExpressionContext) -> Any:
95
- document = context.case.body
131
+ def evaluate(self, output: StepOutput) -> Any | Unresolvable:
132
+ document = output.case.body
96
133
  if self.pointer is None:
97
134
  return document
98
- return pointers.resolve(document, self.pointer[1:])
135
+ return resolve_pointer(document, self.pointer[1:])
99
136
 
100
137
 
101
- @attr.s(slots=True) # pragma: no mutate
138
+ @dataclass
102
139
  class HeaderResponse(Node):
103
140
  """A node for `$response.header` expressions."""
104
141
 
105
- parameter: str = attr.ib() # pragma: no mutate
142
+ parameter: str
143
+ extractor: Extractor | None
106
144
 
107
- def evaluate(self, context: ExpressionContext) -> str:
108
- return context.response.headers[self.parameter]
145
+ __slots__ = ("parameter", "extractor")
109
146
 
147
+ def __init__(self, parameter: str, extractor: Extractor | None = None) -> None:
148
+ self.parameter = parameter
149
+ self.extractor = extractor
110
150
 
111
- @attr.s(slots=True) # pragma: no mutate
151
+ def evaluate(self, output: StepOutput) -> str | Unresolvable:
152
+ value = output.response.headers.get(self.parameter.lower())
153
+ if value is None:
154
+ return UNRESOLVABLE
155
+ if self.extractor is not None:
156
+ return self.extractor.extract(value[0]) or UNRESOLVABLE
157
+ return value[0]
158
+
159
+
160
+ @dataclass
112
161
  class BodyResponse(Node):
113
162
  """A node for `$response.body` expressions."""
114
163
 
115
- pointer: Optional[str] = attr.ib(default=None) # pragma: no mutate
164
+ pointer: str | None
165
+
166
+ __slots__ = ("pointer",)
167
+
168
+ def __init__(self, pointer: str | None = None) -> None:
169
+ self.pointer = pointer
116
170
 
117
- def evaluate(self, context: ExpressionContext) -> Any:
118
- if isinstance(context.response, WSGIResponse):
119
- document = context.response.json
120
- else:
121
- document = context.response.json()
171
+ def evaluate(self, output: StepOutput) -> Any:
172
+ document = output.response.json()
122
173
  if self.pointer is None:
123
174
  # We need the parsed document - data will be serialized before sending to the application
124
175
  return document
125
- return pointers.resolve(document, self.pointer[1:])
176
+ return resolve_pointer(document, self.pointer[1:])
@@ -1,21 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import re
1
4
  from functools import lru_cache
2
- from typing import Generator, List, Union
5
+ from typing import Generator
3
6
 
4
- from . import lexer, nodes
7
+ from . import extractors, lexer, nodes
5
8
  from .errors import RuntimeExpressionError, UnknownToken
6
9
 
7
10
 
8
- @lru_cache() # pragma: no mutate
9
- def parse(expr: str) -> List[nodes.Node]:
11
+ @lru_cache
12
+ def parse(expr: str) -> list[nodes.Node]:
10
13
  """Parse lexical tokens into concrete expression nodes."""
11
14
  return list(_parse(expr))
12
15
 
13
16
 
14
17
  def _parse(expr: str) -> Generator[nodes.Node, None, None]:
15
18
  tokens = lexer.tokenize(expr)
16
- brackets_stack: List[str] = []
19
+ brackets_stack: list[str] = []
17
20
  for token in tokens:
18
- if token.is_string:
21
+ if token.is_string or token.is_dot:
19
22
  yield nodes.String(token.value)
20
23
  elif token.is_variable:
21
24
  yield from _parse_variable(tokens, token, expr)
@@ -43,16 +46,17 @@ def _parse_variable(tokens: lexer.TokenGenerator, token: lexer.Token, expr: str)
43
46
  elif token.value == nodes.NodeType.RESPONSE.value:
44
47
  yield _parse_response(tokens, expr)
45
48
  else:
46
- raise UnknownToken(token.value)
49
+ raise UnknownToken(f"Invalid expression `{expr}`. Unknown token: `{token.value}`")
47
50
 
48
51
 
49
- def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> Union[nodes.BodyRequest, nodes.NonBodyRequest]:
52
+ def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> nodes.BodyRequest | nodes.NonBodyRequest:
50
53
  skip_dot(tokens, "$request")
51
54
  location = next(tokens)
52
55
  if location.value in ("query", "path", "header"):
53
56
  skip_dot(tokens, f"$request.{location.value}")
54
57
  parameter = take_string(tokens, expr)
55
- return nodes.NonBodyRequest(location.value, parameter)
58
+ extractor = take_extractor(tokens, expr, parameter.end)
59
+ return nodes.NonBodyRequest(location.value, parameter.value, extractor)
56
60
  if location.value == "body":
57
61
  try:
58
62
  token = next(tokens)
@@ -63,13 +67,14 @@ def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> Union[nodes.BodyR
63
67
  raise RuntimeExpressionError(f"Invalid expression: {expr}")
64
68
 
65
69
 
66
- def _parse_response(tokens: lexer.TokenGenerator, expr: str) -> Union[nodes.HeaderResponse, nodes.BodyResponse]:
70
+ def _parse_response(tokens: lexer.TokenGenerator, expr: str) -> nodes.HeaderResponse | nodes.BodyResponse:
67
71
  skip_dot(tokens, "$response")
68
72
  location = next(tokens)
69
73
  if location.value == "header":
70
74
  skip_dot(tokens, f"$response.{location.value}")
71
75
  parameter = take_string(tokens, expr)
72
- return nodes.HeaderResponse(parameter)
76
+ extractor = take_extractor(tokens, expr, parameter.end)
77
+ return nodes.HeaderResponse(parameter.value, extractor=extractor)
73
78
  if location.value == "body":
74
79
  try:
75
80
  token = next(tokens)
@@ -86,8 +91,25 @@ def skip_dot(tokens: lexer.TokenGenerator, name: str) -> None:
86
91
  raise RuntimeExpressionError(f"`{name}` expression should be followed by a dot (`.`). Got: {token.value}")
87
92
 
88
93
 
89
- def take_string(tokens: lexer.TokenGenerator, expr: str) -> str:
94
+ def take_string(tokens: lexer.TokenGenerator, expr: str) -> lexer.Token:
90
95
  parameter = next(tokens)
91
96
  if not parameter.is_string:
92
97
  raise RuntimeExpressionError(f"Invalid expression: {expr}")
93
- 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)
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import string
5
+ from base64 import b64encode
6
+ from functools import lru_cache
7
+ from typing import TYPE_CHECKING
8
+
9
+ from schemathesis.transport.serialization import Binary
10
+
11
+ if TYPE_CHECKING:
12
+ from hypothesis import strategies as st
13
+
14
+
15
+ IS_PYPY = platform.python_implementation() == "PyPy"
16
+ STRING_FORMATS: dict[str, st.SearchStrategy] = {}
17
+ # For some reason PyPy can't send header values with codepoints > 128, while CPython can
18
+ if IS_PYPY:
19
+ MAX_HEADER_CODEPOINT = 128
20
+ DEFAULT_HEADER_EXCLUDE_CHARACTERS = "\n\r\x1f\x1e\x1d\x1c"
21
+ else:
22
+ MAX_HEADER_CODEPOINT = 255
23
+ DEFAULT_HEADER_EXCLUDE_CHARACTERS = "\n\r"
24
+
25
+
26
+ def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
27
+ r"""Register a custom Hypothesis strategy for generating string format data.
28
+
29
+ Args:
30
+ name: String format name that matches the "format" keyword in your API schema
31
+ strategy: Hypothesis strategy to generate values for this format
32
+
33
+ Example:
34
+ ```python
35
+ import schemathesis
36
+ from hypothesis import strategies as st
37
+
38
+ # Register phone number format
39
+ phone_strategy = st.from_regex(r"\+1-\d{3}-\d{3}-\d{4}")
40
+ schemathesis.openapi.format("phone", phone_strategy)
41
+
42
+ # Register email with specific domain
43
+ email_strategy = st.from_regex(r"[a-z]+@company\.com")
44
+ schemathesis.openapi.format("company-email", email_strategy)
45
+ ```
46
+
47
+ Schema usage:
48
+ ```yaml
49
+ properties:
50
+ phone:
51
+ type: string
52
+ format: phone # Uses your phone_strategy
53
+ contact_email:
54
+ type: string
55
+ format: company-email # Uses your email_strategy
56
+ ```
57
+
58
+ """
59
+ from hypothesis.strategies import SearchStrategy
60
+
61
+ if not isinstance(name, str):
62
+ raise TypeError(f"name must be of type {str}, not {type(name)}")
63
+ if not isinstance(strategy, SearchStrategy):
64
+ raise TypeError(f"strategy must be of type {SearchStrategy}, not {type(strategy)}")
65
+
66
+ STRING_FORMATS[name] = strategy
67
+
68
+
69
+ def unregister_string_format(name: str) -> None:
70
+ """Remove format strategy from the registry."""
71
+ try:
72
+ del STRING_FORMATS[name]
73
+ except KeyError as exc:
74
+ raise ValueError(f"Unknown Open API format: {name}") from exc
75
+
76
+
77
+ def header_values(
78
+ codec: str | None = None, exclude_characters: str = DEFAULT_HEADER_EXCLUDE_CHARACTERS
79
+ ) -> st.SearchStrategy[str]:
80
+ from hypothesis import strategies as st
81
+
82
+ return st.text(
83
+ alphabet=st.characters(
84
+ min_codepoint=0, max_codepoint=MAX_HEADER_CODEPOINT, codec=codec, exclude_characters=exclude_characters
85
+ )
86
+ # Header values with leading non-visible chars can't be sent with `requests`
87
+ ).map(str.lstrip)
88
+
89
+
90
+ HEADER_FORMAT = "_header_value"
91
+
92
+
93
+ @lru_cache
94
+ def get_default_format_strategies() -> dict[str, st.SearchStrategy]:
95
+ """Get all default "format" strategies."""
96
+ from hypothesis import strategies as st
97
+ from requests.auth import _basic_auth_str
98
+
99
+ def make_basic_auth_str(item: tuple[str, str]) -> str:
100
+ return _basic_auth_str(*item)
101
+
102
+ latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
103
+
104
+ # Define valid characters here to avoid filtering them out in `is_valid_header` later
105
+ header_value = header_values()
106
+
107
+ return {
108
+ "binary": st.binary().map(Binary),
109
+ "byte": st.binary().map(lambda x: b64encode(x).decode()),
110
+ "uuid": st.uuids().map(str),
111
+ # RFC 7230, Section 3.2.6
112
+ "_header_name": st.text(
113
+ min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)
114
+ ),
115
+ HEADER_FORMAT: header_value,
116
+ "_basic_auth": st.tuples(latin1_text, latin1_text).map(make_basic_auth_str),
117
+ "_bearer_auth": header_value.map("Bearer {}".format),
118
+ }
119
+
120
+
121
+ register = register_string_format
122
+ unregister = unregister_string_format