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
@@ -0,0 +1,458 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from functools import lru_cache
5
+
6
+ from schemathesis.core.errors import InternalError
7
+
8
+ try: # pragma: no cover
9
+ import re._constants as sre
10
+ import re._parser as sre_parse
11
+ except ImportError:
12
+ import sre_constants as sre
13
+ import sre_parse
14
+
15
+ ANCHOR = sre.AT
16
+ REPEATS: tuple
17
+ if hasattr(sre, "POSSESSIVE_REPEAT"):
18
+ REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT, sre.POSSESSIVE_REPEAT)
19
+ else:
20
+ REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT)
21
+ LITERAL = sre.LITERAL
22
+ NOT_LITERAL = sre.NOT_LITERAL
23
+ IN = sre.IN
24
+ MAXREPEAT = sre_parse.MAXREPEAT
25
+
26
+
27
+ @lru_cache
28
+ def update_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
29
+ """Update the quantifier of a regular expression based on given min and max lengths."""
30
+ if not pattern or (min_length in (None, 0) and max_length is None):
31
+ return pattern
32
+
33
+ try:
34
+ parsed = sre_parse.parse(pattern)
35
+ updated = _handle_parsed_pattern(parsed, pattern, min_length, max_length)
36
+ try:
37
+ re.compile(updated)
38
+ except re.error as exc:
39
+ raise InternalError(
40
+ f"The combination of min_length={min_length} and max_length={max_length} applied to the original pattern '{pattern}' resulted in an invalid regex: '{updated}'. "
41
+ "This indicates a bug in the regex quantifier merging logic"
42
+ ) from exc
43
+ return updated
44
+ except re.error:
45
+ # Invalid pattern
46
+ return pattern
47
+
48
+
49
+ def _handle_parsed_pattern(parsed: list, pattern: str, min_length: int | None, max_length: int | None) -> str:
50
+ """Handle the parsed pattern and update quantifiers based on different cases."""
51
+ if len(parsed) == 1:
52
+ op, value = parsed[0]
53
+ return _update_quantifier(op, value, pattern, min_length, max_length)
54
+ elif len(parsed) == 2:
55
+ if parsed[0][0] == ANCHOR:
56
+ # Starts with an anchor
57
+ op, value = parsed[1]
58
+ anchor_length = _get_anchor_length(parsed[0][1])
59
+ leading_anchor = pattern[:anchor_length]
60
+ return leading_anchor + _update_quantifier(op, value, pattern[anchor_length:], min_length, max_length)
61
+ if parsed[1][0] == ANCHOR:
62
+ # Ends with an anchor
63
+ op, value = parsed[0]
64
+ anchor_length = _get_anchor_length(parsed[1][1])
65
+ trailing_anchor = pattern[-anchor_length:]
66
+ return _update_quantifier(op, value, pattern[:-anchor_length], min_length, max_length) + trailing_anchor
67
+ elif len(parsed) == 3 and parsed[0][0] == ANCHOR and parsed[2][0] == ANCHOR:
68
+ op, value = parsed[1]
69
+ leading_anchor_length = _get_anchor_length(parsed[0][1])
70
+ trailing_anchor_length = _get_anchor_length(parsed[2][1])
71
+ leading_anchor = pattern[:leading_anchor_length]
72
+ trailing_anchor = pattern[-trailing_anchor_length:]
73
+ # Special case for patterns canonicalisation. Some frameworks generate `\\w\\W` instead of `.`
74
+ # Such patterns lead to significantly slower data generation
75
+ if op == sre.IN and _matches_anything(value):
76
+ op = sre.ANY
77
+ value = None
78
+ inner_pattern = "."
79
+ elif op in REPEATS and len(value[2]) == 1 and value[2][0][0] == sre.IN and _matches_anything(value[2][0][1]):
80
+ value = (value[0], value[1], [(sre.ANY, None)], *value[3:])
81
+ inner_pattern = "."
82
+ else:
83
+ inner_pattern = pattern[leading_anchor_length:-trailing_anchor_length]
84
+ # Single literal has the length of 1, but quantifiers could be != 1, which means we can't merge them
85
+ if op == LITERAL and (
86
+ (min_length is not None and min_length > 1) or (max_length is not None and max_length < 1)
87
+ ):
88
+ return pattern
89
+ return leading_anchor + _update_quantifier(op, value, inner_pattern, min_length, max_length) + trailing_anchor
90
+ elif (
91
+ len(parsed) > 3
92
+ and parsed[0][0] == ANCHOR
93
+ and parsed[-1][0] == ANCHOR
94
+ and all(op == LITERAL or op in REPEATS for op, _ in parsed[1:-1])
95
+ ):
96
+ return _handle_anchored_pattern(parsed, pattern, min_length, max_length)
97
+ return pattern
98
+
99
+
100
+ def _matches_anything(value: list) -> bool:
101
+ """Check if the given pattern is equivalent to '.' (match any character)."""
102
+ # Common forms: [\w\W], [\s\S], etc.
103
+ return value in (
104
+ [(sre.CATEGORY, sre.CATEGORY_WORD), (sre.CATEGORY, sre.CATEGORY_NOT_WORD)],
105
+ [(sre.CATEGORY, sre.CATEGORY_SPACE), (sre.CATEGORY, sre.CATEGORY_NOT_SPACE)],
106
+ [(sre.CATEGORY, sre.CATEGORY_DIGIT), (sre.CATEGORY, sre.CATEGORY_NOT_DIGIT)],
107
+ [(sre.CATEGORY, sre.CATEGORY_NOT_WORD), (sre.CATEGORY, sre.CATEGORY_WORD)],
108
+ [(sre.CATEGORY, sre.CATEGORY_NOT_SPACE), (sre.CATEGORY, sre.CATEGORY_SPACE)],
109
+ [(sre.CATEGORY, sre.CATEGORY_NOT_DIGIT), (sre.CATEGORY, sre.CATEGORY_DIGIT)],
110
+ )
111
+
112
+
113
+ def _handle_anchored_pattern(parsed: list, pattern: str, min_length: int | None, max_length: int | None) -> str:
114
+ """Update regex pattern with multiple quantified patterns to satisfy length constraints."""
115
+ # Extract anchors
116
+ leading_anchor_length = _get_anchor_length(parsed[0][1])
117
+ trailing_anchor_length = _get_anchor_length(parsed[-1][1])
118
+ leading_anchor = pattern[:leading_anchor_length]
119
+ trailing_anchor = pattern[-trailing_anchor_length:]
120
+
121
+ pattern_parts = parsed[1:-1]
122
+
123
+ # Calculate total fixed length and per-repetition lengths
124
+ fixed_length = 0
125
+ quantifier_bounds = []
126
+ repetition_lengths = []
127
+
128
+ for op, value in pattern_parts:
129
+ if op in (LITERAL, NOT_LITERAL):
130
+ fixed_length += 1
131
+ elif op in REPEATS:
132
+ min_repeat, max_repeat, subpattern = value
133
+ quantifier_bounds.append((min_repeat, max_repeat))
134
+ repetition_lengths.append(_calculate_min_repetition_length(subpattern))
135
+
136
+ # Adjust length constraints by subtracting fixed literals length
137
+ if min_length is not None:
138
+ min_length -= fixed_length
139
+ if min_length < 0:
140
+ return pattern
141
+ if max_length is not None:
142
+ max_length -= fixed_length
143
+ if max_length < 0:
144
+ return pattern
145
+
146
+ if not quantifier_bounds:
147
+ return pattern
148
+
149
+ length_distribution = _distribute_length_constraints(quantifier_bounds, repetition_lengths, min_length, max_length)
150
+ if not length_distribution:
151
+ return pattern
152
+
153
+ # Rebuild pattern with updated quantifiers
154
+ result = leading_anchor
155
+ current_position = leading_anchor_length
156
+ distribution_idx = 0
157
+
158
+ for op, value in pattern_parts:
159
+ if op == LITERAL:
160
+ # Check if the literal comes from a bracketed expression,
161
+ # e.g. Python regex parses "[+]" as a single LITERAL token.
162
+ if pattern[current_position] == "[":
163
+ # Find the matching closing bracket.
164
+ end_idx = current_position + 1
165
+ while end_idx < len(pattern):
166
+ # Check for an unescaped closing bracket.
167
+ if pattern[end_idx] == "]" and (end_idx == current_position + 1 or pattern[end_idx - 1] != "\\"):
168
+ end_idx += 1
169
+ break
170
+ end_idx += 1
171
+ # Append the entire character set.
172
+ result += pattern[current_position:end_idx]
173
+ current_position = end_idx
174
+ continue
175
+ if pattern[current_position] == "\\":
176
+ # Escaped value
177
+ result += "\\"
178
+ # Could be an octal value
179
+ if (
180
+ current_position + 2 < len(pattern)
181
+ and pattern[current_position + 1] == "0"
182
+ and pattern[current_position + 2] in ("0", "1", "2", "3", "4", "5", "6", "7")
183
+ ):
184
+ result += pattern[current_position + 1]
185
+ result += pattern[current_position + 2]
186
+ current_position += 3
187
+ continue
188
+ current_position += 2
189
+ else:
190
+ current_position += 1
191
+ result += chr(value)
192
+ else:
193
+ new_min, new_max = length_distribution[distribution_idx]
194
+ next_position = _find_quantified_end(pattern, current_position)
195
+ quantified_segment = pattern[current_position:next_position]
196
+ _, _, subpattern = value
197
+ new_value = (new_min, new_max, subpattern)
198
+
199
+ result += _update_quantifier(op, new_value, quantified_segment, new_min, new_max)
200
+ current_position = next_position
201
+ distribution_idx += 1
202
+
203
+ return result + trailing_anchor
204
+
205
+
206
+ def _find_quantified_end(pattern: str, start: int) -> int:
207
+ """Find the end position of current quantified part."""
208
+ char_class_level = 0
209
+ group_level = 0
210
+
211
+ for i in range(start, len(pattern)):
212
+ char = pattern[i]
213
+
214
+ # Handle character class nesting
215
+ if char == "[":
216
+ char_class_level += 1
217
+ elif char == "]":
218
+ char_class_level -= 1
219
+
220
+ # Handle group nesting
221
+ elif char == "(":
222
+ group_level += 1
223
+ elif char == ")":
224
+ group_level -= 1
225
+
226
+ # Only process quantifiers when we're not inside any nested structure
227
+ elif char_class_level == 0 and group_level == 0:
228
+ if char in "*+?":
229
+ return i + 1
230
+ elif char == "{":
231
+ # Find matching }
232
+ while i < len(pattern) and pattern[i] != "}":
233
+ i += 1
234
+ return i + 1
235
+
236
+ return len(pattern)
237
+
238
+
239
+ def _distribute_length_constraints(
240
+ bounds: list[tuple[int, int]], repetition_lengths: list[int], min_length: int | None, max_length: int | None
241
+ ) -> list[tuple[int, int]] | None:
242
+ """Distribute length constraints among quantified pattern parts."""
243
+ # Handle exact length case with dynamic programming
244
+ if min_length == max_length:
245
+ assert min_length is not None
246
+ target = min_length
247
+ dp: dict[tuple[int, int], list[tuple[int, ...]] | None] = {}
248
+
249
+ def find_valid_combination(pos: int, remaining: int) -> list[tuple[int, ...]] | None:
250
+ if (pos, remaining) in dp:
251
+ return dp[(pos, remaining)]
252
+
253
+ if pos == len(bounds):
254
+ return [()] if remaining == 0 else None
255
+
256
+ max_repeat: int
257
+ min_repeat, max_repeat = bounds[pos]
258
+ repeat_length = repetition_lengths[pos]
259
+
260
+ if max_repeat == MAXREPEAT:
261
+ max_repeat = remaining // repeat_length + 1 if repeat_length > 0 else remaining + 1
262
+
263
+ # Try each possible length for current quantifier
264
+ for repeat_count in range(min_repeat, max_repeat + 1):
265
+ used_length = repeat_count * repeat_length
266
+ if used_length > remaining:
267
+ break
268
+
269
+ rest = find_valid_combination(pos + 1, remaining - used_length)
270
+ if rest is not None:
271
+ dp[(pos, remaining)] = [(repeat_count,) + r for r in rest]
272
+ return dp[(pos, remaining)]
273
+
274
+ dp[(pos, remaining)] = None
275
+ return None
276
+
277
+ distribution = find_valid_combination(0, target)
278
+ if distribution:
279
+ return [(length, length) for length in distribution[0]]
280
+ return None
281
+
282
+ # Handle range case by distributing min/max bounds
283
+ result = []
284
+ remaining_min = min_length or 0
285
+ remaining_max = max_length or MAXREPEAT
286
+
287
+ for min_repeat, max_repeat in bounds:
288
+ if remaining_min > 0:
289
+ part_min = min(max_repeat, max(min_repeat, remaining_min))
290
+ else:
291
+ part_min = min_repeat
292
+
293
+ if remaining_max < MAXREPEAT:
294
+ part_max = min(max_repeat, remaining_max)
295
+ else:
296
+ part_max = max_repeat
297
+
298
+ if part_min > part_max:
299
+ return None
300
+
301
+ result.append((part_min, part_max))
302
+
303
+ remaining_min = max(0, remaining_min - part_min)
304
+ remaining_max -= part_max if part_max != MAXREPEAT else 0
305
+
306
+ if remaining_min > 0 or remaining_max < 0:
307
+ return None
308
+
309
+ return result
310
+
311
+
312
+ def _calculate_min_repetition_length(subpattern: list) -> int:
313
+ """Calculate minimum length contribution per repetition of a quantified group."""
314
+ total = 0
315
+ for op, value in subpattern:
316
+ if op in [LITERAL, NOT_LITERAL, IN, sre.ANY]:
317
+ total += 1
318
+ elif op == sre.SUBPATTERN:
319
+ _, _, _, inner_pattern = value
320
+ total += _calculate_min_repetition_length(inner_pattern)
321
+ elif op in REPEATS:
322
+ min_repeat, _, inner_pattern = value
323
+ inner_min = _calculate_min_repetition_length(inner_pattern)
324
+ total += min_repeat * inner_min
325
+ return total
326
+
327
+
328
+ def _get_anchor_length(node_type: int) -> int:
329
+ """Determine the length of the anchor based on its type."""
330
+ if node_type in {sre.AT_BEGINNING_STRING, sre.AT_END_STRING, sre.AT_BOUNDARY, sre.AT_NON_BOUNDARY}:
331
+ return 2 # \A, \Z, \b, or \B
332
+ return 1 # ^ or $ or their multiline/locale/unicode variants
333
+
334
+
335
+ def _update_quantifier(
336
+ op: int, value: tuple | None, pattern: str, min_length: int | None, max_length: int | None
337
+ ) -> str:
338
+ """Update the quantifier based on the operation type and given constraints."""
339
+ if op in REPEATS and value is not None:
340
+ return _handle_repeat_quantifier(value, pattern, min_length, max_length)
341
+ if op in (LITERAL, NOT_LITERAL, IN) and max_length != 0:
342
+ return _handle_literal_or_in_quantifier(pattern, min_length, max_length)
343
+ if op == sre.ANY and value is None:
344
+ # Equivalent to `.` which is in turn is the same as `.{1}`
345
+ return _handle_repeat_quantifier(
346
+ SINGLE_ANY,
347
+ pattern,
348
+ min_length,
349
+ max_length,
350
+ )
351
+ return pattern
352
+
353
+
354
+ SINGLE_ANY = sre_parse.parse(".{1}")[0][1]
355
+
356
+
357
+ def _handle_repeat_quantifier(
358
+ value: tuple[int, int, tuple], pattern: str, min_length: int | None, max_length: int | None
359
+ ) -> str:
360
+ """Handle repeat quantifiers (e.g., '+', '*', '?')."""
361
+ min_repeat, max_repeat, _ = value
362
+
363
+ # First, analyze the inner pattern
364
+ inner = _strip_quantifier(pattern)
365
+ if inner.startswith("(") and inner.endswith(")"):
366
+ inner = inner[1:-1]
367
+
368
+ # Determine the length of the inner pattern
369
+ inner_length = 1 # default assumption for non-literal patterns
370
+ try:
371
+ parsed = sre_parse.parse(inner)
372
+ if all(item[0] == LITERAL for item in parsed):
373
+ inner_length = len(parsed)
374
+ if max_length and max_length > 0 and inner_length > max_length:
375
+ return pattern
376
+ except re.error:
377
+ pass
378
+
379
+ if inner_length == 0:
380
+ # Empty pattern contributes 0 chars regardless of repetitions
381
+ # For length constraints, only 0 repetitions make sense
382
+ if min_length is not None and min_length > 0:
383
+ return pattern # Can't satisfy positive length with empty pattern
384
+ return f"({inner})" + _build_quantifier(0, 0)
385
+
386
+ # Convert external length constraints to repetition constraints
387
+ external_min_repeat = None
388
+ external_max_repeat = None
389
+
390
+ if min_length is not None:
391
+ # Need at least ceil(min_length / inner_length) repetitions
392
+ external_min_repeat = (min_length + inner_length - 1) // inner_length
393
+
394
+ if max_length is not None:
395
+ # Can have at most floor(max_length / inner_length) repetitions
396
+ external_max_repeat = max_length // inner_length
397
+
398
+ # Merge original repetition constraints with external constraints
399
+ final_min_repeat = min_repeat
400
+ if external_min_repeat is not None:
401
+ final_min_repeat = max(min_repeat, external_min_repeat)
402
+
403
+ final_max_repeat = max_repeat
404
+ if external_max_repeat is not None:
405
+ if max_repeat == MAXREPEAT:
406
+ final_max_repeat = external_max_repeat
407
+ else:
408
+ final_max_repeat = min(max_repeat, external_max_repeat)
409
+
410
+ if final_min_repeat > final_max_repeat:
411
+ return pattern
412
+
413
+ return f"({inner})" + _build_quantifier(final_min_repeat, final_max_repeat)
414
+
415
+
416
+ def _handle_literal_or_in_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
417
+ """Handle literal or character class quantifiers."""
418
+ min_length = 1 if min_length is None else max(min_length, 1)
419
+ if pattern.startswith("(") and pattern.endswith(")"):
420
+ pattern = pattern[1:-1]
421
+ return f"({pattern})" + _build_quantifier(min_length, max_length)
422
+
423
+
424
+ def _build_quantifier(minimum: int | None, maximum: int | None) -> str:
425
+ """Construct a quantifier string based on min and max values."""
426
+ if maximum == MAXREPEAT or maximum is None:
427
+ return f"{{{minimum or 0},}}"
428
+ if minimum == maximum:
429
+ return f"{{{minimum}}}"
430
+ return f"{{{minimum or 0},{maximum}}}"
431
+
432
+
433
+ def _build_size(min_repeat: int, max_repeat: int, min_length: int | None, max_length: int | None) -> tuple[int, int]:
434
+ """Merge the current repetition constraints with the provided min and max lengths."""
435
+ if min_length is not None:
436
+ min_repeat = max(min_repeat, min_length)
437
+ if max_length is not None:
438
+ if max_repeat == MAXREPEAT:
439
+ max_repeat = max_length
440
+ else:
441
+ max_repeat = min(max_repeat, max_length)
442
+ return min_repeat, max_repeat
443
+
444
+
445
+ def _strip_quantifier(pattern: str) -> str:
446
+ """Remove quantifier from the pattern."""
447
+ # Lazy & posessive quantifiers
448
+ for marker in ("*?", "+?", "??", "*+", "?+", "++"):
449
+ if pattern.endswith(marker) and not pattern.endswith(rf"\{marker}"):
450
+ return pattern[:-2]
451
+ for marker in ("?", "*", "+"):
452
+ if pattern.endswith(marker) and not pattern.endswith(rf"\{marker}"):
453
+ pattern = pattern[:-1]
454
+ if pattern.endswith("}") and "{" in pattern:
455
+ # Find the start of the exact quantifier and drop everything since that index
456
+ idx = pattern.rfind("{")
457
+ pattern = pattern[:idx]
458
+ return pattern
@@ -1,115 +1,94 @@
1
- from copy import deepcopy
1
+ from __future__ import annotations
2
+
3
+ import sys
2
4
  from functools import lru_cache
3
- from typing import Any, Callable, Dict, List, Tuple, Union, overload
5
+ from typing import Any, Callable, Dict, Union
4
6
  from urllib.request import urlopen
5
7
 
6
- import jsonschema
7
8
  import requests
8
- import yaml
9
-
10
- from ...constants import DEFAULT_RESPONSE_TIMEOUT
11
- from ...utils import StringDatesYAMLLoader
12
- from .converter import to_json_schema_recursive
13
9
 
14
- # Reference resolving will stop after this depth
15
- RECURSION_DEPTH_LIMIT = 100
10
+ from schemathesis.core.compat import RefResolutionError, RefResolver
11
+ from schemathesis.core.deserialization import deserialize_yaml
12
+ from schemathesis.core.errors import RemoteDocumentError
13
+ from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
16
14
 
17
15
 
18
- def load_file_impl(location: str, opener: Callable) -> Dict[str, Any]:
16
+ def load_file_impl(location: str, opener: Callable) -> dict[str, Any]:
19
17
  """Load a schema from the given file."""
20
18
  with opener(location) as fd:
21
- return yaml.load(fd, StringDatesYAMLLoader)
19
+ return deserialize_yaml(fd)
22
20
 
23
21
 
24
- @lru_cache()
25
- def load_file(location: str) -> Dict[str, Any]:
22
+ @lru_cache
23
+ def load_file(location: str) -> dict[str, Any]:
26
24
  """Load a schema from the given file."""
27
25
  return load_file_impl(location, open)
28
26
 
29
27
 
30
- @lru_cache()
31
- def load_file_uri(location: str) -> Dict[str, Any]:
28
+ @lru_cache
29
+ def load_file_uri(location: str) -> dict[str, Any]:
32
30
  """Load a schema from the given file uri."""
33
31
  return load_file_impl(location, urlopen)
34
32
 
35
33
 
34
+ _HTML_MARKERS = (b"<!doctype", b"<html", b"<head", b"<body")
35
+
36
+
37
+ def _looks_like_html(content_type: str | None, body: bytes) -> bool:
38
+ if content_type and "html" in content_type.lower():
39
+ return True
40
+ head = body.lstrip()[:64].lower()
41
+ return any(head.startswith(m) for m in _HTML_MARKERS)
42
+
43
+
36
44
  def load_remote_uri(uri: str) -> Any:
37
45
  """Load the resource and parse it as YAML / JSON."""
38
- response = requests.get(uri, timeout=DEFAULT_RESPONSE_TIMEOUT / 1000)
39
- return yaml.load(response.content, StringDatesYAMLLoader)
46
+ response = requests.get(uri, timeout=DEFAULT_RESPONSE_TIMEOUT)
47
+ content_type = response.headers.get("Content-Type", "")
48
+ body = response.content or b""
40
49
 
50
+ def _suffix() -> str:
51
+ return f"(HTTP {response.status_code}, Content-Type={content_type}, size={len(body)})"
41
52
 
42
- JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
53
+ if not (200 <= response.status_code < 300):
54
+ raise RemoteDocumentError(f"Failed to fetch {_suffix()}")
43
55
 
56
+ if _looks_like_html(content_type, body):
57
+ raise RemoteDocumentError(f"Expected YAML/JSON, got HTML {_suffix()}")
44
58
 
45
- class InliningResolver(jsonschema.RefResolver):
46
- """Inlines resolved schemas."""
59
+ document = deserialize_yaml(response.content)
47
60
 
48
- def __init__(self, *args: Any, **kwargs: Any) -> None:
49
- kwargs.setdefault(
50
- "handlers", {"file": load_file_uri, "": load_file, "http": load_remote_uri, "https": load_remote_uri}
61
+ if not isinstance(document, (dict, list)):
62
+ raise RemoteDocumentError(
63
+ f"Remote document is parsed as {type(document).__name__}, but an object/array is expected {_suffix()}"
51
64
  )
52
- super().__init__(*args, **kwargs)
53
65
 
54
- @overload # pragma: no mutate
55
- def resolve_all(
56
- self, item: Dict[str, Any], recursion_level: int = 0
57
- ) -> Dict[str, Any]: # pylint: disable=function-redefined
58
- pass
59
-
60
- @overload # pragma: no mutate
61
- def resolve_all(self, item: List, recursion_level: int = 0) -> List: # pylint: disable=function-redefined
62
- pass
63
-
64
- # pylint: disable=function-redefined
65
- def resolve_all(self, item: JSONType, recursion_level: int = 0) -> JSONType:
66
- """Recursively resolve all references in the given object."""
67
- if recursion_level > RECURSION_DEPTH_LIMIT:
68
- return item
69
- if isinstance(item, dict):
70
- ref = item.get("$ref")
71
- if ref is not None and isinstance(ref, str):
72
- with self.resolving(ref) as resolved:
73
- # If the next level of recursion exceeds the limit, then we need to copy it explicitly
74
- # In other cases, this method create new objects for mutable types (dict & list)
75
- next_recursion_level = recursion_level + 1
76
- if next_recursion_level > RECURSION_DEPTH_LIMIT:
77
- return deepcopy(resolved)
78
- return self.resolve_all(resolved, next_recursion_level)
79
- return {key: self.resolve_all(sub_item, recursion_level) for key, sub_item in item.items()}
80
- if isinstance(item, list):
81
- return [self.resolve_all(sub_item, recursion_level) for sub_item in item]
82
- return item
83
-
84
- def resolve_in_scope(self, definition: Dict[str, Any], scope: str) -> Tuple[List[str], Dict[str, Any]]:
85
- scopes = [scope]
86
- # if there is `$ref` then we have a scope change that should be used during validation later to
87
- # resolve nested references correctly
88
- if "$ref" in definition:
89
- self.push_scope(scope)
90
- try:
91
- new_scope, definition = deepcopy(self.resolve(definition["$ref"]))
92
- finally:
93
- self.pop_scope()
94
- scopes.append(new_scope)
95
- return scopes, definition
66
+ return document
96
67
 
97
68
 
98
- class ConvertingResolver(InliningResolver):
99
- """Convert resolved OpenAPI schemas to JSON Schema.
69
+ JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
100
70
 
101
- When recursive schemas are validated we need to have resolved documents properly converted.
102
- This approach is the simplest one, since this logic isolated in a single place.
103
- """
104
71
 
105
- def __init__(self, *args: Any, nullable_name: Any, is_response_schema: bool = False, **kwargs: Any) -> None:
72
+ class ReferenceResolver(RefResolver):
73
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
74
+ kwargs.setdefault(
75
+ "handlers", {"file": load_file_uri, "": load_file, "http": load_remote_uri, "https": load_remote_uri}
76
+ )
106
77
  super().__init__(*args, **kwargs)
107
- self.nullable_name = nullable_name
108
- self.is_response_schema = is_response_schema
109
78
 
110
- def resolve(self, ref: str) -> Tuple[str, Any]:
111
- url, document = super().resolve(ref)
112
- document = to_json_schema_recursive(
113
- document, nullable_name=self.nullable_name, is_response_schema=self.is_response_schema
114
- )
115
- return url, document
79
+ if sys.version_info >= (3, 11):
80
+
81
+ def resolve(self, ref: str) -> tuple[str, Any]:
82
+ try:
83
+ return super().resolve(ref)
84
+ except RefResolutionError as exc:
85
+ exc.add_note(ref)
86
+ raise
87
+ else:
88
+
89
+ def resolve(self, ref: str) -> tuple[str, Any]:
90
+ try:
91
+ return super().resolve(ref)
92
+ except RefResolutionError as exc:
93
+ exc.__notes__ = [ref]
94
+ raise