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,315 +0,0 @@
1
- from __future__ import annotations
2
- import binascii
3
- import os
4
- from dataclasses import dataclass
5
- from io import BytesIO
6
- from typing import (
7
- TYPE_CHECKING,
8
- Any,
9
- Callable,
10
- Collection,
11
- Dict,
12
- Generator,
13
- cast,
14
- Protocol,
15
- runtime_checkable,
16
- )
17
-
18
- from .internal.copy import fast_deepcopy
19
- from ._xml import _to_xml
20
- from .transports.content_types import (
21
- is_json_media_type,
22
- is_plain_text_media_type,
23
- is_xml_media_type,
24
- parse_content_type,
25
- )
26
-
27
- if TYPE_CHECKING:
28
- from .models import Case
29
-
30
-
31
- SERIALIZERS: dict[str, type[Serializer]] = {}
32
-
33
-
34
- @dataclass
35
- class Binary(str):
36
- """A wrapper around `bytes` to resolve OpenAPI and JSON Schema `format` discrepancies.
37
-
38
- Treat `bytes` as a valid type, allowing generation of bytes for OpenAPI `format` values like `binary` or `file`
39
- that JSON Schema expects to be strings.
40
- """
41
-
42
- data: bytes
43
-
44
-
45
- @dataclass
46
- class SerializerContext:
47
- """The context for serialization process.
48
-
49
- :ivar Case case: Generated example that is being processed.
50
- """
51
-
52
- case: Case
53
-
54
- @property
55
- def media_type(self) -> str:
56
- # `media_type` is a string, otherwise we won't serialize anything
57
- return cast(str, self.case.media_type)
58
-
59
- # Note on type casting below.
60
- # If we serialize data, then there should be non-empty definition for it in the first place
61
- # Therefore `schema` is never `None` if called from here. However, `APIOperation.get_raw_payload_schema` is
62
- # generic and can be called from other places where it may return `None`
63
-
64
- def get_raw_payload_schema(self) -> dict[str, Any]:
65
- schema = self.case.operation.get_raw_payload_schema(self.media_type)
66
- return cast(Dict[str, Any], schema)
67
-
68
- def get_resolved_payload_schema(self) -> dict[str, Any]:
69
- schema = self.case.operation.get_resolved_payload_schema(self.media_type)
70
- return cast(Dict[str, Any], schema)
71
-
72
-
73
- @runtime_checkable
74
- class Serializer(Protocol):
75
- """Transform generated data to a form supported by the transport layer.
76
-
77
- For example, to handle multipart payloads, we need to serialize them differently for
78
- `requests` and `werkzeug` transports.
79
- """
80
-
81
- def as_requests(self, context: SerializerContext, payload: Any) -> dict[str, Any]:
82
- raise NotImplementedError
83
-
84
- def as_werkzeug(self, context: SerializerContext, payload: Any) -> dict[str, Any]:
85
- raise NotImplementedError
86
-
87
-
88
- def register(media_type: str, *, aliases: Collection[str] = ()) -> Callable[[type[Serializer]], type[Serializer]]:
89
- """Register a serializer for the given media type.
90
-
91
- Schemathesis uses ``requests`` for regular network calls and ``werkzeug`` for WSGI applications. Your serializer
92
- should have two methods, ``as_requests`` and ``as_werkzeug``, providing keyword arguments that Schemathesis will
93
- pass to ``requests.request`` and ``werkzeug.Client.open`` respectively.
94
-
95
- .. code-block:: python
96
-
97
- @register("text/csv")
98
- class CSVSerializer:
99
- def as_requests(self, context, value):
100
- return {"data": to_csv(value)}
101
-
102
- def as_werkzeug(self, context, value):
103
- return {"data": to_csv(value)}
104
-
105
- The primary purpose of serializers is to transform data from its Python representation to the format suitable
106
- for making an API call. The generated data structure depends on your schema, but its type matches
107
- Python equivalents to the JSON Schema types.
108
-
109
- """
110
-
111
- def wrapper(serializer: type[Serializer]) -> type[Serializer]:
112
- if not issubclass(serializer, Serializer):
113
- raise TypeError(
114
- f"`{serializer.__name__}` is not a valid serializer. "
115
- f"Check `schemathesis.serializers.Serializer` documentation for examples."
116
- )
117
- SERIALIZERS[media_type] = serializer
118
- for alias in aliases:
119
- SERIALIZERS[alias] = serializer
120
- return serializer
121
-
122
- return wrapper
123
-
124
-
125
- def unregister(media_type: str) -> None:
126
- """Remove registered serializer for the given media type."""
127
- del SERIALIZERS[media_type]
128
-
129
-
130
- def _to_json(value: Any) -> dict[str, Any]:
131
- if isinstance(value, bytes):
132
- # Possible to get via explicit examples, e.g. `externalValue`
133
- return {"data": value}
134
- if isinstance(value, Binary):
135
- return {"data": value.data}
136
- if value is None:
137
- # If the body is `None`, then the app expects `null`, but `None` is also the default value for the `json`
138
- # argument in `requests.request` and `werkzeug.Client.open` which makes these cases indistinguishable.
139
- # Therefore we explicitly create such payload
140
- return {"data": b"null"}
141
- return {"json": value}
142
-
143
-
144
- @register("application/json", aliases=("text/json",))
145
- class JSONSerializer:
146
- def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
147
- return _to_json(value)
148
-
149
- def as_werkzeug(self, context: SerializerContext, value: Any) -> dict[str, Any]:
150
- return _to_json(value)
151
-
152
-
153
- def _to_yaml(value: Any) -> dict[str, Any]:
154
- import yaml
155
-
156
- try:
157
- from yaml import CSafeDumper as SafeDumper
158
- except ImportError:
159
- from yaml import SafeDumper # type: ignore
160
-
161
- if isinstance(value, bytes):
162
- return {"data": value}
163
- if isinstance(value, Binary):
164
- return {"data": value.data}
165
- return {"data": yaml.dump(value, Dumper=SafeDumper)}
166
-
167
-
168
- @register("text/yaml", aliases=("text/x-yaml", "application/x-yaml", "text/vnd.yaml"))
169
- class YAMLSerializer:
170
- def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
171
- return _to_yaml(value)
172
-
173
- def as_werkzeug(self, context: SerializerContext, value: Any) -> dict[str, Any]:
174
- return _to_yaml(value)
175
-
176
-
177
- @register("application/xml", aliases=("text/xml",))
178
- class XMLSerializer:
179
- def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
180
- return _to_xml(value, context.get_raw_payload_schema(), context.get_resolved_payload_schema())
181
-
182
- def as_werkzeug(self, context: SerializerContext, value: Any) -> dict[str, Any]:
183
- return _to_xml(value, context.get_raw_payload_schema(), context.get_resolved_payload_schema())
184
-
185
-
186
- def _should_coerce_to_bytes(item: Any) -> bool:
187
- """Whether the item should be converted to bytes."""
188
- # These types are OK in forms, others should be coerced to bytes
189
- return isinstance(item, Binary) or not isinstance(item, (bytes, str, int))
190
-
191
-
192
- def _prepare_form_data(data: dict[str, Any]) -> dict[str, Any]:
193
- """Make the generated data suitable for sending as multipart.
194
-
195
- If the schema is loose, Schemathesis can generate data that can't be sent as multipart. In these cases,
196
- we convert it to bytes and send it as-is, ignoring any conversion errors.
197
-
198
- NOTE. This behavior might change in the future.
199
- """
200
- for name, value in data.items():
201
- if isinstance(value, list):
202
- data[name] = [_to_bytes(item) if _should_coerce_to_bytes(item) else item for item in value]
203
- elif _should_coerce_to_bytes(value):
204
- data[name] = _to_bytes(value)
205
- return data
206
-
207
-
208
- def _to_bytes(value: Any) -> bytes:
209
- """Convert the input value to bytes and ignore any conversion errors."""
210
- if isinstance(value, bytes):
211
- return value
212
- if isinstance(value, Binary):
213
- return value.data
214
- return str(value).encode(errors="ignore")
215
-
216
-
217
- def choose_boundary() -> str:
218
- """Random boundary name."""
219
- return binascii.hexlify(os.urandom(16)).decode("ascii")
220
-
221
-
222
- def _encode_multipart(value: Any, boundary: str) -> bytes:
223
- """Encode any value as multipart.
224
-
225
- NOTE. It doesn't aim to be 100% correct multipart payload, but rather a way to send data which is not intended to
226
- be used as multipart, in cases when the API schema dictates so.
227
- """
228
- # For such cases we stringify the value and wrap it to a randomly-generated boundary
229
- body = BytesIO()
230
- body.write(f"--{boundary}\r\n".encode())
231
- body.write(str(value).encode())
232
- body.write(f"--{boundary}--\r\n".encode("latin-1"))
233
- return body.getvalue()
234
-
235
-
236
- @register("multipart/form-data")
237
- class MultipartSerializer:
238
- def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
239
- if isinstance(value, bytes):
240
- return {"data": value}
241
- if isinstance(value, dict):
242
- value = fast_deepcopy(value)
243
- multipart = _prepare_form_data(value)
244
- files, data = context.case.operation.prepare_multipart(multipart)
245
- return {"files": files, "data": data}
246
- # Uncommon schema. For example - `{"type": "string"}`
247
- boundary = choose_boundary()
248
- raw_data = _encode_multipart(value, boundary)
249
- content_type = f"multipart/form-data; boundary={boundary}"
250
- return {"data": raw_data, "headers": {"Content-Type": content_type}}
251
-
252
- def as_werkzeug(self, context: SerializerContext, value: Any) -> dict[str, Any]:
253
- return {"data": value}
254
-
255
-
256
- @register("application/x-www-form-urlencoded")
257
- class URLEncodedFormSerializer:
258
- def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
259
- return {"data": value}
260
-
261
- def as_werkzeug(self, context: SerializerContext, value: Any) -> dict[str, Any]:
262
- return {"data": value}
263
-
264
-
265
- @register("text/plain")
266
- class TextSerializer:
267
- def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
268
- if isinstance(value, bytes):
269
- return {"data": value}
270
- return {"data": str(value).encode("utf8")}
271
-
272
- def as_werkzeug(self, context: SerializerContext, value: Any) -> dict[str, Any]:
273
- if isinstance(value, bytes):
274
- return {"data": value}
275
- return {"data": str(value)}
276
-
277
-
278
- @register("application/octet-stream")
279
- class OctetStreamSerializer:
280
- def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
281
- return {"data": _to_bytes(value)}
282
-
283
- def as_werkzeug(self, context: SerializerContext, value: Any) -> dict[str, Any]:
284
- return {"data": _to_bytes(value)}
285
-
286
-
287
- def get_matching_media_types(media_type: str) -> Generator[str, None, None]:
288
- """Get all registered media types matching the given media type."""
289
- if media_type == "*/*":
290
- # Shortcut to avoid comparing all values
291
- yield from iter(SERIALIZERS)
292
- else:
293
- main, sub = parse_content_type(media_type)
294
- if main == "application" and (sub == "json" or sub.endswith("+json")):
295
- yield media_type
296
- else:
297
- for registered_media_type in SERIALIZERS:
298
- target_main, target_sub = parse_content_type(registered_media_type)
299
- if main in ("*", target_main) and sub in ("*", target_sub):
300
- yield registered_media_type
301
-
302
-
303
- def get_first_matching_media_type(media_type: str) -> str | None:
304
- return next(get_matching_media_types(media_type), None)
305
-
306
-
307
- def get(media_type: str) -> type[Serializer] | None:
308
- """Get an appropriate serializer for the given media type."""
309
- if is_json_media_type(media_type):
310
- media_type = "application/json"
311
- if is_plain_text_media_type(media_type):
312
- media_type = "text/plain"
313
- if is_xml_media_type(media_type):
314
- media_type = "application/xml"
315
- return SERIALIZERS.get(media_type)
@@ -1,18 +0,0 @@
1
- from . import auth, ci, hosts
2
- from .constants import (
3
- DEFAULT_HOSTNAME,
4
- DEFAULT_HOSTS_PATH,
5
- DEFAULT_PROTOCOL,
6
- DEFAULT_URL,
7
- HOSTNAME_ENV_VAR,
8
- HOSTS_PATH_ENV_VAR,
9
- PROTOCOL_ENV_VAR,
10
- REPORT_ENV_VAR,
11
- TELEMETRY_ENV_VAR,
12
- TOKEN_ENV_VAR,
13
- URL_ENV_VAR,
14
- WORKER_CHECK_PERIOD,
15
- WORKER_FINISH_TIMEOUT,
16
- )
17
- from .events import Completed, Error, Event, Failed, Metadata, Timeout
18
- from .report import FileReportHandler, ServiceReportHandler
@@ -1,11 +0,0 @@
1
- from . import metadata
2
- from .constants import DEFAULT_HOSTNAME, DEFAULT_PROTOCOL
3
-
4
-
5
- def login(token: str, hostname: str = DEFAULT_HOSTNAME, protocol: str = DEFAULT_PROTOCOL, verify: bool = True) -> str:
6
- from .client import ServiceClient
7
-
8
- """Make a login request to SaaS."""
9
- client = ServiceClient(f"{protocol}://{hostname}", token, verify=verify)
10
- response = client.login(metadata=metadata.Metadata())
11
- return response.username
@@ -1,201 +0,0 @@
1
- from __future__ import annotations
2
- import enum
3
- import os
4
- from dataclasses import asdict, dataclass
5
- from typing import Protocol, runtime_checkable
6
-
7
-
8
- @enum.unique
9
- class CIProvider(enum.Enum):
10
- """A set of supported CI providers."""
11
-
12
- GITHUB = "github"
13
- GITLAB = "gitlab"
14
-
15
-
16
- @runtime_checkable
17
- class Environment(Protocol):
18
- provider: CIProvider
19
- variable_name: str
20
- verbose_name: str
21
-
22
- @classmethod
23
- def is_set(cls) -> bool:
24
- pass
25
-
26
- @classmethod
27
- def from_env(cls) -> Environment:
28
- pass
29
-
30
- def asdict(self) -> dict[str, str | None]:
31
- pass
32
-
33
- def as_env(self) -> dict[str, str | None]:
34
- pass
35
-
36
-
37
- def environment() -> Environment | None:
38
- """Collect environment data for a supported CI provider."""
39
- provider = detect()
40
- if provider == CIProvider.GITHUB:
41
- return GitHubActionsEnvironment.from_env()
42
- if provider == CIProvider.GITLAB:
43
- return GitLabCIEnvironment.from_env()
44
- return None
45
-
46
-
47
- def detect() -> CIProvider | None:
48
- """Detect the current CI provider."""
49
- if GitHubActionsEnvironment.is_set():
50
- return GitHubActionsEnvironment.provider
51
- if GitLabCIEnvironment.is_set():
52
- return GitLabCIEnvironment.provider
53
- return None
54
-
55
-
56
- def _asdict(env: Environment) -> dict[str, str | None]:
57
- data = asdict(env) # type: ignore
58
- data["provider"] = env.provider.value
59
- return data
60
-
61
-
62
- @dataclass
63
- class GitHubActionsEnvironment:
64
- """Useful data to capture from GitHub Actions environment."""
65
-
66
- provider = CIProvider.GITHUB
67
- variable_name = "GITHUB_ACTIONS"
68
- verbose_name = "GitHub Actions"
69
- asdict = _asdict
70
-
71
- # GitHub API URL.
72
- # For example, `https://api.github.com`
73
- api_url: str
74
- # The owner and repository name.
75
- # For example, `schemathesis/schemathesis`.
76
- repository: str
77
- # The name of the person or app that initiated the workflow.
78
- # For example, `Stranger6667`
79
- actor: str
80
- # The commit SHA that triggered the workflow.
81
- # For example, `e56e13224f08469841e106449f6467b769e2afca`
82
- sha: str
83
- # A unique number for each workflow run within a repository.
84
- # For example, `1658821493`.
85
- run_id: str
86
- # The name of the workflow.
87
- # For example, `My test workflow`.
88
- workflow: str
89
- # The head ref or source branch of the pull request in a workflow run.
90
- # For example, `dd/report-ci`.
91
- head_ref: str | None
92
- # The name of the base ref or target branch of the pull request in a workflow run.
93
- # For example, `main`.
94
- base_ref: str | None
95
- # The branch or tag ref that triggered the workflow run.
96
- # This is only set if a branch or tag is available for the event type.
97
- # For example, `refs/pull/1533/merge`
98
- ref: str | None
99
- # The Schemathesis GitHub Action version.
100
- # For example `v1.0.1`
101
- action_ref: str | None
102
-
103
- @classmethod
104
- def is_set(cls) -> bool:
105
- return os.getenv(cls.variable_name) == "true"
106
-
107
- @classmethod
108
- def from_env(cls) -> GitHubActionsEnvironment:
109
- return cls(
110
- api_url=os.environ["GITHUB_API_URL"],
111
- repository=os.environ["GITHUB_REPOSITORY"],
112
- actor=os.environ["GITHUB_ACTOR"],
113
- sha=os.environ["GITHUB_SHA"],
114
- run_id=os.environ["GITHUB_RUN_ID"],
115
- workflow=os.environ["GITHUB_WORKFLOW"],
116
- head_ref=os.getenv("GITHUB_HEAD_REF"),
117
- base_ref=os.getenv("GITHUB_BASE_REF"),
118
- ref=os.getenv("GITHUB_REF"),
119
- action_ref=os.getenv("SCHEMATHESIS_ACTION_REF"),
120
- )
121
-
122
- def as_env(self) -> dict[str, str | None]:
123
- return {
124
- "GITHUB_API_URL": self.api_url,
125
- "GITHUB_REPOSITORY": self.repository,
126
- "GITHUB_ACTOR": self.actor,
127
- "GITHUB_SHA": self.sha,
128
- "GITHUB_RUN_ID": self.run_id,
129
- "GITHUB_WORKFLOW": self.workflow,
130
- "GITHUB_HEAD_REF": self.head_ref,
131
- "GITHUB_BASE_REF": self.base_ref,
132
- "GITHUB_REF": self.ref,
133
- "SCHEMATHESIS_ACTION_REF": self.action_ref,
134
- }
135
-
136
-
137
- @dataclass
138
- class GitLabCIEnvironment:
139
- """Useful data to capture from GitLab CI environment."""
140
-
141
- provider = CIProvider.GITLAB
142
- variable_name = "GITLAB_CI"
143
- verbose_name = "GitLab CI"
144
- asdict = _asdict
145
-
146
- # GitLab API URL
147
- # For example, `https://gitlab.com/api/v4`
148
- api_v4_url: str
149
- # The ID of the current project.
150
- # For example, `12345678`
151
- project_id: str
152
- # The username of the user who started the job.
153
- # For example, `Stranger6667`
154
- user_login: str
155
- # The commit revision the project is built for.
156
- # For example, `e56e13224f08469841e106449f6467b769e2afca`
157
- commit_sha: str
158
- # NOTE: `commit_branch` and `merge_request_source_branch_name` may mean the same thing, but they are available
159
- # in different context. There are also a couple of `CI_BUILD_*` variables that could be used, but they are
160
- # not documented.
161
- # The commit branch name. Not available in merge request pipelines or tag pipelines.
162
- # For example, `dd/report-ci`.
163
- commit_branch: str | None
164
- # The source branch name of the merge request. Only available in merge request pipelines.
165
- # For example, `dd/report-ci`.
166
- merge_request_source_branch_name: str | None
167
- # The target branch name of the merge request.
168
- # For example, `main`.
169
- merge_request_target_branch_name: str | None
170
- # The project-level internal ID of the merge request.
171
- # For example, `42`.
172
- merge_request_iid: str | None
173
-
174
- @classmethod
175
- def is_set(cls) -> bool:
176
- return os.getenv(cls.variable_name) == "true"
177
-
178
- @classmethod
179
- def from_env(cls) -> GitLabCIEnvironment:
180
- return cls(
181
- api_v4_url=os.environ["CI_API_V4_URL"],
182
- project_id=os.environ["CI_PROJECT_ID"],
183
- user_login=os.environ["GITLAB_USER_LOGIN"],
184
- commit_sha=os.environ["CI_COMMIT_SHA"],
185
- commit_branch=os.getenv("CI_COMMIT_BRANCH"),
186
- merge_request_source_branch_name=os.getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"),
187
- merge_request_target_branch_name=os.getenv("CI_MERGE_REQUEST_TARGET_BRANCH_NAME"),
188
- merge_request_iid=os.getenv("CI_MERGE_REQUEST_IID"),
189
- )
190
-
191
- def as_env(self) -> dict[str, str | None]:
192
- return {
193
- "CI_API_V4_URL": self.api_v4_url,
194
- "CI_PROJECT_ID": self.project_id,
195
- "GITLAB_USER_LOGIN": self.user_login,
196
- "CI_COMMIT_SHA": self.commit_sha,
197
- "CI_COMMIT_BRANCH": self.commit_branch,
198
- "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": self.merge_request_source_branch_name,
199
- "CI_MERGE_REQUEST_TARGET_BRANCH_NAME": self.merge_request_target_branch_name,
200
- "CI_MERGE_REQUEST_IID": self.merge_request_iid,
201
- }
@@ -1,100 +0,0 @@
1
- from __future__ import annotations
2
- import hashlib
3
- import http
4
- from dataclasses import asdict
5
- from typing import Any
6
- from urllib.parse import urljoin
7
-
8
- import requests
9
- from requests.adapters import HTTPAdapter, Retry
10
-
11
- from ..constants import USER_AGENT
12
- from .ci import CIProvider
13
- from .constants import CI_PROVIDER_HEADER, REPORT_CORRELATION_ID_HEADER, REQUEST_TIMEOUT, UPLOAD_SOURCE_HEADER
14
- from .metadata import Metadata
15
- from .models import (
16
- ProjectDetails,
17
- AuthResponse,
18
- FailedUploadResponse,
19
- UploadResponse,
20
- UploadSource,
21
- ProjectEnvironment,
22
- Specification,
23
- )
24
-
25
-
26
- def response_hook(response: requests.Response, **_kwargs: Any) -> None:
27
- if response.status_code != http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
28
- response.raise_for_status()
29
-
30
-
31
- class ServiceClient(requests.Session):
32
- """A more convenient session to send requests to Schemathesis.io."""
33
-
34
- def __init__(self, base_url: str, token: str | None, *, timeout: int = REQUEST_TIMEOUT, verify: bool = True):
35
- super().__init__()
36
- self.timeout = timeout
37
- self.verify = verify
38
- self.base_url = base_url
39
- self.headers["User-Agent"] = USER_AGENT
40
- if token is not None:
41
- self.headers["Authorization"] = f"Bearer {token}"
42
- # Automatically check responses for 4XX and 5XX
43
- self.hooks["response"] = [response_hook] # type: ignore
44
- adapter = HTTPAdapter(max_retries=Retry(5))
45
- self.mount("https://", adapter)
46
- self.mount("http://", adapter)
47
-
48
- def request(self, method: str, url: str, *args: Any, **kwargs: Any) -> requests.Response: # type: ignore
49
- kwargs.setdefault("timeout", self.timeout)
50
- kwargs.setdefault("verify", self.verify)
51
- # All requests will be done against the base url
52
- url = urljoin(self.base_url, url)
53
- return super().request(method, url, *args, **kwargs)
54
-
55
- def get_api_details(self, name: str) -> ProjectDetails:
56
- """Get information about an API."""
57
- response = self.get(f"/cli/projects/{name}/")
58
- data = response.json()
59
- return ProjectDetails(
60
- environments=[
61
- ProjectEnvironment(
62
- url=environment["url"],
63
- name=environment["name"],
64
- description=environment["description"],
65
- is_default=environment["is_default"],
66
- )
67
- for environment in data["environments"]
68
- ],
69
- specification=Specification(schema=data["specification"]["schema"]),
70
- )
71
-
72
- def login(self, metadata: Metadata) -> AuthResponse:
73
- """Send a login request."""
74
- response = self.post("/auth/cli/login/", json={"metadata": asdict(metadata)})
75
- data = response.json()
76
- return AuthResponse(username=data["username"])
77
-
78
- def upload_report(
79
- self,
80
- report: bytes,
81
- correlation_id: str | None = None,
82
- ci_provider: CIProvider | None = None,
83
- source: UploadSource = UploadSource.DEFAULT,
84
- ) -> UploadResponse | FailedUploadResponse:
85
- """Upload test run report to Schemathesis.io."""
86
- headers = {
87
- "Content-Type": "application/x-gtar",
88
- "X-Checksum-Blake2s256": hashlib.blake2s(report).hexdigest(),
89
- UPLOAD_SOURCE_HEADER: source.value,
90
- }
91
- if correlation_id is not None:
92
- headers[REPORT_CORRELATION_ID_HEADER] = correlation_id
93
- if ci_provider is not None:
94
- headers[CI_PROVIDER_HEADER] = ci_provider.value
95
- # Do not limit the upload timeout
96
- response = self.post("/reports/upload/", report, headers=headers, timeout=None)
97
- data = response.json()
98
- if response.status_code == http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
99
- return FailedUploadResponse(detail=data["detail"])
100
- return UploadResponse(message=data["message"], next_url=data["next"], correlation_id=data["correlation_id"])
@@ -1,38 +0,0 @@
1
- import os
2
- import pathlib
3
-
4
- IS_CI = os.getenv("CI") == "true"
5
-
6
- DEFAULT_HOSTNAME = "api.schemathesis.io"
7
- # The main Schemathesis.io API address
8
- DEFAULT_URL = f"https://{DEFAULT_HOSTNAME}/"
9
- DEFAULT_PROTOCOL = "https"
10
- # An HTTP header name to store report correlation id
11
- REPORT_CORRELATION_ID_HEADER = "X-Schemathesis-Report-Correlation-Id"
12
- CI_PROVIDER_HEADER = "X-Schemathesis-CI-Provider"
13
- UPLOAD_SOURCE_HEADER = "X-Schemathesis-Upload-Source"
14
- # A sentinel to signal the worker thread to stop
15
- STOP_MARKER = object()
16
- # Timeout for each API call
17
- REQUEST_TIMEOUT = 1
18
- # The time the main thread will wait for the worker thread to finish its job before exiting
19
- WORKER_FINISH_TIMEOUT = 10.0
20
- # A period between checking the worker thread for events
21
- # Decrease the frequency for CI environment to avoid too much output from the waiting spinner
22
- WORKER_CHECK_PERIOD = 0.1 if IS_CI else 0.005
23
- # Wait until the worker terminates
24
- WORKER_JOIN_TIMEOUT = 10
25
- # Version of the hosts file format
26
- HOSTS_FORMAT_VERSION = "0.1"
27
- # Upload report version
28
- REPORT_FORMAT_VERSION = "1"
29
- # Default path to the hosts file
30
- DEFAULT_HOSTS_PATH = pathlib.Path.home() / ".config/schemathesis/hosts.toml"
31
- TOKEN_ENV_VAR = "SCHEMATHESIS_TOKEN"
32
- HOSTNAME_ENV_VAR = "SCHEMATHESIS_HOSTNAME"
33
- PROTOCOL_ENV_VAR = "SCHEMATHESIS_PROTOCOL"
34
- HOSTS_PATH_ENV_VAR = "SCHEMATHESIS_HOSTS_PATH"
35
- URL_ENV_VAR = "SCHEMATHESIS_URL"
36
- REPORT_ENV_VAR = "SCHEMATHESIS_REPORT"
37
- TELEMETRY_ENV_VAR = "SCHEMATHESIS_TELEMETRY"
38
- DOCKER_IMAGE_ENV_VAR = "SCHEMATHESIS_DOCKER_IMAGE"