schemathesis 3.19.7__py3-none-any.whl → 3.20.1__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 (46) hide show
  1. schemathesis/_compat.py +3 -2
  2. schemathesis/_hypothesis.py +21 -6
  3. schemathesis/_xml.py +177 -0
  4. schemathesis/auths.py +48 -10
  5. schemathesis/cli/__init__.py +77 -19
  6. schemathesis/cli/callbacks.py +42 -18
  7. schemathesis/cli/context.py +2 -1
  8. schemathesis/cli/output/default.py +102 -34
  9. schemathesis/cli/sanitization.py +15 -0
  10. schemathesis/code_samples.py +141 -0
  11. schemathesis/constants.py +1 -24
  12. schemathesis/exceptions.py +127 -26
  13. schemathesis/experimental/__init__.py +85 -0
  14. schemathesis/extra/pytest_plugin.py +10 -4
  15. schemathesis/fixups/__init__.py +8 -2
  16. schemathesis/fixups/fast_api.py +11 -1
  17. schemathesis/fixups/utf8_bom.py +7 -1
  18. schemathesis/hooks.py +63 -0
  19. schemathesis/lazy.py +10 -4
  20. schemathesis/loaders.py +57 -0
  21. schemathesis/models.py +120 -96
  22. schemathesis/parameters.py +3 -0
  23. schemathesis/runner/__init__.py +3 -0
  24. schemathesis/runner/events.py +55 -20
  25. schemathesis/runner/impl/core.py +54 -54
  26. schemathesis/runner/serialization.py +75 -34
  27. schemathesis/sanitization.py +248 -0
  28. schemathesis/schemas.py +21 -6
  29. schemathesis/serializers.py +32 -3
  30. schemathesis/service/serialization.py +5 -1
  31. schemathesis/specs/graphql/loaders.py +44 -13
  32. schemathesis/specs/graphql/schemas.py +56 -25
  33. schemathesis/specs/openapi/_hypothesis.py +11 -23
  34. schemathesis/specs/openapi/definitions.py +572 -0
  35. schemathesis/specs/openapi/loaders.py +100 -49
  36. schemathesis/specs/openapi/parameters.py +2 -2
  37. schemathesis/specs/openapi/schemas.py +87 -13
  38. schemathesis/specs/openapi/security.py +1 -0
  39. schemathesis/stateful.py +2 -2
  40. schemathesis/utils.py +30 -9
  41. schemathesis-3.20.1.dist-info/METADATA +342 -0
  42. {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/RECORD +45 -39
  43. schemathesis-3.19.7.dist-info/METADATA +0 -291
  44. {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/WHEEL +0 -0
  45. {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/entry_points.txt +0 -0
  46. {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,12 @@
1
- from dataclasses import dataclass
1
+ import enum
2
+ import json
3
+ from dataclasses import dataclass, field
2
4
  from hashlib import sha1
3
5
  from json import JSONDecodeError
4
6
  from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, NoReturn, Optional, Tuple, Type, Union
5
7
 
6
8
  import hypothesis.errors
7
- import requests
8
- from jsonschema import ValidationError
9
+ from jsonschema import RefResolutionError, ValidationError
9
10
 
10
11
  from .constants import SERIALIZERS_SUGGESTION_MESSAGE
11
12
  from .failures import FailureContext
@@ -139,8 +140,11 @@ def get_timeout_error(deadline: Union[float, int]) -> Type[CheckFailed]:
139
140
  return _get_hashed_exception("TimeoutError", str(deadline))
140
141
 
141
142
 
143
+ SCHEMA_ERROR_SUGGESTION = "Ensure that the definition complies with the OpenAPI specification"
144
+
145
+
142
146
  @dataclass
143
- class InvalidSchema(Exception):
147
+ class OperationSchemaError(Exception):
144
148
  """Schema associated with an API operation contains an error."""
145
149
 
146
150
  __module__ = "builtins"
@@ -149,6 +153,44 @@ class InvalidSchema(Exception):
149
153
  method: Optional[str] = None
150
154
  full_path: Optional[str] = None
151
155
 
156
+ @classmethod
157
+ def from_jsonschema_error(
158
+ cls, error: ValidationError, path: Optional[str], method: Optional[str], full_path: Optional[str]
159
+ ) -> "OperationSchemaError":
160
+ if error.absolute_path:
161
+ part = error.absolute_path[-1]
162
+ if isinstance(part, int) and len(error.absolute_path) > 1:
163
+ parent = error.absolute_path[-2]
164
+ message = f"Invalid definition for element at index {part} in `{parent}`"
165
+ else:
166
+ message = f"Invalid `{part}` definition"
167
+ else:
168
+ message = "Invalid schema definition"
169
+ error_path = " -> ".join((str(entry) for entry in error.path)) or "[root]"
170
+ message += f"\n\nLocation:\n {error_path}"
171
+ instance = truncated_json(error.instance)
172
+ message += f"\n\nProblematic definition:\n{instance}"
173
+ message += "\n\nError details:\n "
174
+ # This default message contains the instance which we already printed
175
+ if "is not valid under any of the given schemas" in error.message:
176
+ message += "The provided definition doesn't match any of the expected formats or types."
177
+ else:
178
+ message += error.message
179
+ message += f"\n\n{SCHEMA_ERROR_SUGGESTION}"
180
+ return cls(message, path=path, method=method, full_path=full_path)
181
+
182
+ @classmethod
183
+ def from_reference_resolution_error(
184
+ cls, error: RefResolutionError, path: Optional[str], method: Optional[str], full_path: Optional[str]
185
+ ) -> "OperationSchemaError":
186
+ message = "Unresolvable JSON pointer in the schema"
187
+ # Get the pointer value from "Unresolvable JSON pointer: 'components/UnknownParameter'"
188
+ pointer = str(error).split(": ", 1)[-1]
189
+ message += f"\n\nError details:\n JSON pointer: {pointer}"
190
+ message += "\n This typically means that the schema is referencing a component that doesn't exist."
191
+ message += f"\n\n{SCHEMA_ERROR_SUGGESTION}"
192
+ return cls(message, path=path, method=method, full_path=full_path)
193
+
152
194
  def as_failing_test_function(self) -> Callable:
153
195
  """Create a test function that will fail.
154
196
 
@@ -162,6 +204,26 @@ class InvalidSchema(Exception):
162
204
  return actual_test
163
205
 
164
206
 
207
+ def truncated_json(data: Any, max_lines: int = 10, max_width: int = 80) -> str:
208
+ # Convert JSON to string with indentation
209
+ indent = 4
210
+ serialized = json.dumps(data, indent=indent)
211
+
212
+ # Split string by lines
213
+
214
+ lines = [line[: max_width - 3] + "..." if len(line) > max_width else line for line in serialized.split("\n")]
215
+
216
+ if len(lines) <= max_lines:
217
+ return "\n".join(lines)
218
+
219
+ truncated_lines = lines[: max_lines - 1]
220
+ indentation = " " * indent
221
+ truncated_lines.append(f"{indentation}// Output truncated...")
222
+ truncated_lines.append(lines[-1])
223
+
224
+ return "\n".join(truncated_lines)
225
+
226
+
165
227
  class DeadlineExceeded(Exception):
166
228
  """Test took too long to run."""
167
229
 
@@ -176,9 +238,46 @@ class DeadlineExceeded(Exception):
176
238
  )
177
239
 
178
240
 
179
- class SchemaLoadingError(ValueError):
241
+ @enum.unique
242
+ class SchemaErrorType(enum.Enum):
243
+ # Connection related issues
244
+ CONNECTION_SSL = "connection_ssl"
245
+ CONNECTION_OTHER = "connection_other"
246
+ NETWORK_OTHER = "network_other"
247
+
248
+ # HTTP error codes
249
+ HTTP_SERVER_ERROR = "http_server_error"
250
+ HTTP_CLIENT_ERROR = "http_client_error"
251
+ HTTP_NOT_FOUND = "http_not_found"
252
+ HTTP_FORBIDDEN = "http_forbidden"
253
+
254
+ # Content decoding issues
255
+ UNEXPECTED_CONTENT_TYPE = "unexpected_content_type"
256
+ YAML_NUMERIC_STATUS_CODES = "yaml_numeric_status_codes"
257
+ YAML_NON_STRING_KEYS = "yaml_non_string_keys"
258
+
259
+ # Open API validation
260
+ OPEN_API_INVALID_SCHEMA = "open_api_invalid_schema"
261
+ OPEN_API_UNSPECIFIED_VERSION = "open_api_unspecified_version"
262
+ OPEN_API_UNSUPPORTED_VERSION = "open_api_unsupported_version"
263
+
264
+ # Unclassified
265
+ UNCLASSIFIED = "unclassified"
266
+
267
+
268
+ @dataclass
269
+ class SchemaError(RuntimeError):
180
270
  """Failed to load an API schema."""
181
271
 
272
+ type: SchemaErrorType
273
+ message: str
274
+ url: Optional[str] = None
275
+ response: Optional["GenericResponse"] = None
276
+ extras: List[str] = field(default_factory=list)
277
+
278
+ def __str__(self) -> str:
279
+ return self.message
280
+
182
281
 
183
282
  class NonCheckError(Exception):
184
283
  """An error happened in side the runner, but is not related to failed checks.
@@ -205,13 +304,35 @@ class SkipTest(BaseException):
205
304
  SERIALIZATION_NOT_POSSIBLE_MESSAGE = (
206
305
  f"Schemathesis can't serialize data to any of the defined media types: {{}} \n{SERIALIZERS_SUGGESTION_MESSAGE}"
207
306
  )
307
+ NAMESPACE_DEFINITION_URL = "https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#xmlNamespace"
308
+ UNBOUND_PREFIX_MESSAGE_TEMPLATE = (
309
+ "Unbound prefix: `{prefix}`. "
310
+ "You need to define this namespace in your API schema via the `xml.namespace` keyword. "
311
+ f"See more at {NAMESPACE_DEFINITION_URL}"
312
+ )
208
313
 
209
314
  SERIALIZATION_FOR_TYPE_IS_NOT_POSSIBLE_MESSAGE = (
210
315
  f"Schemathesis can't serialize data to {{}} \n{SERIALIZERS_SUGGESTION_MESSAGE}"
211
316
  )
212
317
 
213
318
 
214
- class SerializationNotPossible(Exception):
319
+ class SerializationError(Exception):
320
+ """Serialization can not be done."""
321
+
322
+ __module__ = "builtins"
323
+
324
+
325
+ class UnboundPrefixError(SerializationError):
326
+ """XML serialization error.
327
+
328
+ It happens when the schema does not define a namespace that is used by some of its parts.
329
+ """
330
+
331
+ def __init__(self, prefix: str):
332
+ super().__init__(UNBOUND_PREFIX_MESSAGE_TEMPLATE.format(prefix=prefix))
333
+
334
+
335
+ class SerializationNotPossible(SerializationError):
215
336
  """Not possible to serialize to any of the media types defined for some API operation.
216
337
 
217
338
  Usually, there is still `application/json` along with less common ones, but this error happens when there is no
@@ -233,25 +354,5 @@ class InvalidRegularExpression(Exception):
233
354
  __module__ = "builtins"
234
355
 
235
356
 
236
- @dataclass
237
- class HTTPError(Exception):
238
- response: "GenericResponse"
239
- url: str
240
-
241
- @classmethod
242
- def raise_for_status(cls, response: requests.Response) -> None:
243
- try:
244
- response.raise_for_status()
245
- except requests.HTTPError as exc:
246
- raise cls(response=response, url=response.url) from exc
247
-
248
- @classmethod
249
- def check_response(cls, response: requests.Response, schema_path: str) -> None:
250
- # Raising exception to provide unified behavior
251
- # E.g. it will be handled in CLI - a proper error message will be shown
252
- if 400 <= response.status_code < 600:
253
- raise cls(response=response, url=schema_path)
254
-
255
-
256
357
  class UsageError(Exception):
257
358
  """Incorrect usage of Schemathesis functions."""
@@ -0,0 +1,85 @@
1
+ import threading
2
+ from dataclasses import dataclass, field
3
+
4
+
5
+ @dataclass(eq=False)
6
+ class Experiment:
7
+ name: str
8
+ verbose_name: str
9
+ env_var: str
10
+ description: str
11
+ discussion_url: str
12
+ _storage: "ExperimentSet" = field(repr=False)
13
+
14
+ def enable(self) -> None:
15
+ self._storage.enable(self)
16
+
17
+ def disable(self) -> None:
18
+ self._storage.disable(self)
19
+
20
+ @property
21
+ def is_enabled(self) -> bool:
22
+ return self._storage.is_enabled(self)
23
+
24
+
25
+ @dataclass
26
+ class ExperimentSet:
27
+ _local_data: threading.local = field(default_factory=threading.local, repr=False)
28
+
29
+ def __post_init__(self) -> None:
30
+ self.available = set()
31
+ self.enabled = set()
32
+
33
+ @property
34
+ def available(self) -> set:
35
+ return self._local_data.available
36
+
37
+ @available.setter
38
+ def available(self, value: set) -> None:
39
+ self._local_data.available = value
40
+
41
+ @property
42
+ def enabled(self) -> set:
43
+ return self._local_data.enabled
44
+
45
+ @enabled.setter
46
+ def enabled(self, value: set) -> None:
47
+ self._local_data.enabled = value
48
+
49
+ def create_experiment(
50
+ self, name: str, verbose_name: str, env_var: str, description: str, discussion_url: str
51
+ ) -> Experiment:
52
+ instance = Experiment(
53
+ name=name,
54
+ verbose_name=verbose_name,
55
+ env_var=f"{ENV_PREFIX}_{env_var}",
56
+ description=description,
57
+ discussion_url=discussion_url,
58
+ _storage=self,
59
+ )
60
+ self.available.add(instance)
61
+ return instance
62
+
63
+ def enable(self, feature: Experiment) -> None:
64
+ self.enabled.add(feature)
65
+
66
+ def disable(self, feature: Experiment) -> None:
67
+ self.enabled.discard(feature)
68
+
69
+ def disable_all(self) -> None:
70
+ self.enabled.clear()
71
+
72
+ def is_enabled(self, feature: Experiment) -> bool:
73
+ return feature in self.enabled
74
+
75
+
76
+ ENV_PREFIX = "SCHEMATHESIS_EXPERIMENTAL"
77
+ GLOBAL_EXPERIMENTS = ExperimentSet()
78
+
79
+ OPEN_API_3_1 = GLOBAL_EXPERIMENTS.create_experiment(
80
+ name="openapi-3.1",
81
+ verbose_name="OpenAPI 3.1",
82
+ env_var="OPENAPI_3_1",
83
+ description="Support for response validation",
84
+ discussion_url="https://github.com/schemathesis/schemathesis/discussions/1822",
85
+ )
@@ -14,7 +14,7 @@ from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
14
14
 
15
15
  from .._hypothesis import create_test
16
16
  from ..constants import IS_PYTEST_ABOVE_7, IS_PYTEST_ABOVE_54, RECURSIVE_REFERENCE_ERROR_MESSAGE
17
- from ..exceptions import InvalidSchema, SkipTest
17
+ from ..exceptions import OperationSchemaError, SkipTest
18
18
  from ..models import APIOperation
19
19
  from ..utils import (
20
20
  PARAMETRIZE_MARKER,
@@ -87,7 +87,9 @@ class SchemathesisCase(PyCollector):
87
87
  def _get_test_name(self, operation: APIOperation) -> str:
88
88
  return f"{self.name}[{operation.verbose_name}]"
89
89
 
90
- def _gen_items(self, result: Result[APIOperation, InvalidSchema]) -> Generator[SchemathesisFunction, None, None]:
90
+ def _gen_items(
91
+ self, result: Result[APIOperation, OperationSchemaError]
92
+ ) -> Generator[SchemathesisFunction, None, None]:
91
93
  """Generate all tests for the given API operation.
92
94
 
93
95
  Could produce more than one test item if
@@ -183,7 +185,11 @@ class SchemathesisCase(PyCollector):
183
185
  """Generate different test items for all API operations available in the given schema."""
184
186
  try:
185
187
  items = [
186
- item for operation in self.schemathesis_case.get_all_operations() for item in self._gen_items(operation)
188
+ item
189
+ for operation in self.schemathesis_case.get_all_operations(
190
+ hooks=getattr(self.test_function, "_schemathesis_hooks", None)
191
+ )
192
+ for item in self._gen_items(operation)
187
193
  ]
188
194
  if not items:
189
195
  fail_on_no_matches(self.nodeid)
@@ -234,7 +240,7 @@ def pytest_pyfunc_call(pyfuncitem): # type:ignore
234
240
  try:
235
241
  outcome.get_result()
236
242
  except InvalidArgument as exc:
237
- raise InvalidSchema(exc.args[0]) from None
243
+ raise OperationSchemaError(exc.args[0]) from None
238
244
  except HypothesisRefResolutionError:
239
245
  pytest.skip(RECURSIVE_REFERENCE_ERROR_MESSAGE)
240
246
  except SkipTest as exc:
@@ -3,6 +3,7 @@ from typing import Iterable, Optional
3
3
  from . import fast_api, utf8_bom
4
4
 
5
5
  ALL_FIXUPS = {"fast_api": fast_api, "utf8_bom": utf8_bom}
6
+ ALL_FIXUP_NAMES = list(ALL_FIXUPS.keys())
6
7
 
7
8
 
8
9
  def install(fixups: Optional[Iterable[str]] = None) -> None:
@@ -12,7 +13,7 @@ def install(fixups: Optional[Iterable[str]] = None) -> None:
12
13
 
13
14
  :param fixups: Names of fixups to install.
14
15
  """
15
- fixups = fixups or list(ALL_FIXUPS.keys())
16
+ fixups = fixups or ALL_FIXUP_NAMES
16
17
  for name in fixups:
17
18
  ALL_FIXUPS[name].install() # type: ignore
18
19
 
@@ -24,6 +25,11 @@ def uninstall(fixups: Optional[Iterable[str]] = None) -> None:
24
25
 
25
26
  :param fixups: Names of fixups to uninstall.
26
27
  """
27
- fixups = fixups or list(ALL_FIXUPS.keys())
28
+ fixups = fixups or ALL_FIXUP_NAMES
28
29
  for name in fixups:
29
30
  ALL_FIXUPS[name].uninstall() # type: ignore
31
+
32
+
33
+ def is_installed(name: str) -> bool:
34
+ """Check whether fixup is installed."""
35
+ return ALL_FIXUPS[name].is_installed()
@@ -1,6 +1,8 @@
1
1
  from typing import Any, Dict
2
2
 
3
- from ..hooks import HookContext, register, unregister
3
+ from ..hooks import HookContext
4
+ from ..hooks import is_installed as global_is_installed
5
+ from ..hooks import register, unregister
4
6
  from ..utils import traverse_schema
5
7
 
6
8
 
@@ -12,7 +14,15 @@ def uninstall() -> None:
12
14
  unregister(before_load_schema)
13
15
 
14
16
 
17
+ def is_installed() -> bool:
18
+ return global_is_installed("before_load_schema", before_load_schema)
19
+
20
+
15
21
  def before_load_schema(context: HookContext, schema: Dict[str, Any]) -> None:
22
+ adjust_schema(schema)
23
+
24
+
25
+ def adjust_schema(schema: Dict[str, Any]) -> None:
16
26
  traverse_schema(schema, _handle_boundaries)
17
27
 
18
28
 
@@ -3,7 +3,9 @@ from typing import TYPE_CHECKING
3
3
  import requests
4
4
 
5
5
  from ..constants import BOM_MARK
6
- from ..hooks import HookContext, register, unregister
6
+ from ..hooks import HookContext
7
+ from ..hooks import is_installed as global_is_installed
8
+ from ..hooks import register, unregister
7
9
 
8
10
  if TYPE_CHECKING:
9
11
  from .. import Case, GenericResponse
@@ -17,6 +19,10 @@ def uninstall() -> None:
17
19
  unregister(after_call)
18
20
 
19
21
 
22
+ def is_installed() -> bool:
23
+ return global_is_installed("after_call", after_call)
24
+
25
+
20
26
  def after_call(context: HookContext, case: "Case", response: "GenericResponse") -> None:
21
27
  if isinstance(response, requests.Response) and response.encoding == "utf-8" and response.text[0:1] == BOM_MARK:
22
28
  response.encoding = "utf-8-sig"
schemathesis/hooks.py CHANGED
@@ -3,6 +3,7 @@ from collections import defaultdict
3
3
  from copy import deepcopy
4
4
  from dataclasses import dataclass, field
5
5
  from enum import Enum, unique
6
+ from functools import partial
6
7
  from typing import TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, Dict, List, Optional, Union, cast
7
8
 
8
9
  from hypothesis import strategies as st
@@ -178,6 +179,28 @@ class HookDispatcher:
178
179
  """Get a list of hooks registered for a name."""
179
180
  return self._hooks.get(name, [])
180
181
 
182
+ def is_installed(self, name: str, needle: Callable) -> bool:
183
+ for hook in self.get_all_by_name(name):
184
+ if hook is needle:
185
+ return True
186
+ return False
187
+
188
+ def apply_to_container(
189
+ self, strategy: st.SearchStrategy, container: str, context: HookContext
190
+ ) -> st.SearchStrategy:
191
+ for hook in self.get_all_by_name(f"before_generate_{container}"):
192
+ strategy = hook(context, strategy)
193
+ for hook in self.get_all_by_name(f"filter_{container}"):
194
+ hook = partial(hook, context)
195
+ strategy = strategy.filter(hook)
196
+ for hook in self.get_all_by_name(f"map_{container}"):
197
+ hook = partial(hook, context)
198
+ strategy = strategy.map(hook)
199
+ for hook in self.get_all_by_name(f"flatmap_{container}"):
200
+ hook = partial(hook, context)
201
+ strategy = strategy.flatmap(hook)
202
+ return strategy
203
+
181
204
  def dispatch(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None:
182
205
  """Run all hooks for the given name."""
183
206
  for hook in self.get_all_by_name(name):
@@ -200,9 +223,43 @@ class HookDispatcher:
200
223
  self._hooks = defaultdict(list)
201
224
 
202
225
 
226
+ def apply_to_all_dispatchers(
227
+ operation: "APIOperation",
228
+ context: HookContext,
229
+ hooks: Optional[HookDispatcher],
230
+ strategy: st.SearchStrategy,
231
+ container: str,
232
+ ) -> st.SearchStrategy:
233
+ """Apply all hooks related to the given location."""
234
+ strategy = GLOBAL_HOOK_DISPATCHER.apply_to_container(strategy, container, context)
235
+ strategy = operation.schema.hooks.apply_to_container(strategy, container, context)
236
+ if hooks is not None:
237
+ strategy = hooks.apply_to_container(strategy, container, context)
238
+ return strategy
239
+
240
+
241
+ def should_skip_operation(dispatcher: HookDispatcher, context: HookContext) -> bool:
242
+ for hook in dispatcher.get_all_by_name("filter_operations"):
243
+ if not hook(context):
244
+ return True
245
+ return False
246
+
247
+
203
248
  all_scopes = HookDispatcher.register_spec(list(HookScope))
204
249
 
205
250
 
251
+ for action in ("filter", "map", "flatmap"):
252
+ for target in ("path_parameters", "query", "headers", "cookies", "body", "case"):
253
+ exec(
254
+ f"""
255
+ @all_scopes
256
+ def {action}_{target}(context: HookContext, {target}: Any) -> Any:
257
+ pass
258
+ """,
259
+ globals(),
260
+ )
261
+
262
+
206
263
  @all_scopes
207
264
  def before_generate_path_parameters(context: HookContext, strategy: st.SearchStrategy) -> st.SearchStrategy:
208
265
  """Called on a strategy that generates values for ``path_parameters``."""
@@ -238,6 +295,11 @@ def before_process_path(context: HookContext, path: str, methods: Dict[str, Any]
238
295
  """Called before API path is processed."""
239
296
 
240
297
 
298
+ @all_scopes
299
+ def filter_operations(context: HookContext) -> Optional[bool]:
300
+ """Decide whether testing of this particular API operation should be skipped or not."""
301
+
302
+
241
303
  @HookDispatcher.register_spec([HookScope.GLOBAL])
242
304
  def before_load_schema(context: HookContext, raw_schema: Dict[str, Any]) -> None:
243
305
  """Called before schema instance is created."""
@@ -294,6 +356,7 @@ def after_call(context: HookContext, case: "Case", response: GenericResponse) ->
294
356
  GLOBAL_HOOK_DISPATCHER = HookDispatcher(scope=HookScope.GLOBAL)
295
357
  dispatch = GLOBAL_HOOK_DISPATCHER.dispatch
296
358
  get_all_by_name = GLOBAL_HOOK_DISPATCHER.get_all_by_name
359
+ is_installed = GLOBAL_HOOK_DISPATCHER.is_installed
297
360
  collect_statistic = GLOBAL_HOOK_DISPATCHER.collect_statistic
298
361
  register = GLOBAL_HOOK_DISPATCHER.register
299
362
  unregister = GLOBAL_HOOK_DISPATCHER.unregister
schemathesis/lazy.py CHANGED
@@ -13,8 +13,9 @@ from pytest_subtests import SubTests, nullcontext
13
13
 
14
14
  from ._compat import MultipleFailures
15
15
  from .auths import AuthStorage
16
- from .constants import FLAKY_FAILURE_MESSAGE, CodeSampleStyle
17
- from .exceptions import CheckFailed, InvalidSchema, SkipTest, get_grouped_exception
16
+ from .code_samples import CodeSampleStyle
17
+ from .constants import FLAKY_FAILURE_MESSAGE
18
+ from .exceptions import CheckFailed, OperationSchemaError, SkipTest, get_grouped_exception
18
19
  from .hooks import HookDispatcher, HookScope
19
20
  from .models import APIOperation
20
21
  from .schemas import BaseSchema
@@ -49,6 +50,7 @@ class LazySchema:
49
50
  data_generation_methods: Union[DataGenerationMethodInput, NotSet] = NOT_SET
50
51
  code_sample_style: CodeSampleStyle = CodeSampleStyle.default()
51
52
  rate_limiter: Optional[Limiter] = None
53
+ sanitize_output: bool = True
52
54
 
53
55
  def hook(self, hook: Union[str, Callable]) -> Callable:
54
56
  return self.hooks.register(hook)
@@ -115,12 +117,13 @@ class LazySchema:
115
117
  code_sample_style=_code_sample_style,
116
118
  app=self.app,
117
119
  rate_limiter=self.rate_limiter,
120
+ sanitize_output=self.sanitize_output,
118
121
  )
119
122
  fixtures = get_fixtures(test, request, given_kwargs)
120
123
  # Changing the node id is required for better reporting - the method and path will appear there
121
124
  node_id = request.node._nodeid
122
125
  settings = getattr(wrapped_test, "_hypothesis_internal_use_settings", None)
123
- tests = list(schema.get_all_tests(test, settings, _given_kwargs=given_kwargs))
126
+ tests = list(schema.get_all_tests(test, settings, hooks=self.hooks, _given_kwargs=given_kwargs))
124
127
  if not tests:
125
128
  fail_on_no_matches(node_id)
126
129
  request.session.testscollected += len(tests)
@@ -244,7 +247,7 @@ def run_subtest(
244
247
  SEPARATOR = "\n===================="
245
248
 
246
249
 
247
- def _schema_error(subtests: SubTests, error: InvalidSchema, node_id: str) -> None:
250
+ def _schema_error(subtests: SubTests, error: OperationSchemaError, node_id: str) -> None:
248
251
  """Run a failing test, that will show the underlying problem."""
249
252
  sub_test = error.as_failing_test_function()
250
253
  # `full_path` is always available in this case
@@ -252,6 +255,7 @@ def _schema_error(subtests: SubTests, error: InvalidSchema, node_id: str) -> Non
252
255
  if error.method:
253
256
  kwargs["method"] = error.method.upper()
254
257
  subtests.item._nodeid = _get_partial_node_name(node_id, **kwargs)
258
+ __tracebackhide__ = True
255
259
  with subtests.test(**kwargs):
256
260
  sub_test()
257
261
 
@@ -274,6 +278,7 @@ def get_schema(
274
278
  data_generation_methods: Union[DataGenerationMethodInput, NotSet] = NOT_SET,
275
279
  code_sample_style: CodeSampleStyle,
276
280
  rate_limiter: Optional[Limiter],
281
+ sanitize_output: bool,
277
282
  ) -> BaseSchema:
278
283
  """Loads a schema from the fixture."""
279
284
  schema = request.getfixturevalue(name)
@@ -294,6 +299,7 @@ def get_schema(
294
299
  data_generation_methods=data_generation_methods,
295
300
  code_sample_style=code_sample_style,
296
301
  rate_limiter=rate_limiter,
302
+ sanitize_output=sanitize_output,
297
303
  )
298
304
 
299
305
 
@@ -0,0 +1,57 @@
1
+ import http.client
2
+ import re
3
+ from typing import Callable, TypeVar, cast
4
+
5
+ import requests
6
+
7
+ from .exceptions import SchemaError, SchemaErrorType
8
+ from .utils import GenericResponse
9
+
10
+ R = TypeVar("R", bound=GenericResponse)
11
+
12
+
13
+ def remove_ssl_line_number(text: str) -> str:
14
+ return re.sub(r"\(_ssl\.c:\d+\)", "", text)
15
+
16
+
17
+ def load_schema_from_url(loader: Callable[[], R]) -> R:
18
+ try:
19
+ response = loader()
20
+ except requests.RequestException as exc:
21
+ request = cast(requests.PreparedRequest, exc.request)
22
+ if isinstance(exc, requests.exceptions.SSLError):
23
+ message = "SSL verification problem"
24
+ type_ = SchemaErrorType.CONNECTION_SSL
25
+ reason = str(exc.args[0].reason)
26
+ extra = [remove_ssl_line_number(reason)]
27
+ elif isinstance(exc, requests.exceptions.ConnectionError):
28
+ message = "Connection failed"
29
+ type_ = SchemaErrorType.CONNECTION_OTHER
30
+ _, reason = exc.args[0].reason.args[0].split(":", maxsplit=1)
31
+ extra = [reason.strip()]
32
+ else:
33
+ message = "Network problem"
34
+ type_ = SchemaErrorType.NETWORK_OTHER
35
+ extra = []
36
+ raise SchemaError(message=message, type=type_, url=request.url, response=exc.response, extras=extra) from exc
37
+ _raise_for_status(response)
38
+ return response
39
+
40
+
41
+ def _raise_for_status(response: GenericResponse) -> None:
42
+ status_code = response.status_code
43
+ reason = http.client.responses.get(status_code, "Unknown")
44
+ if status_code >= 500:
45
+ message = f"Failed to load schema due to server error (HTTP {status_code} {reason})"
46
+ type_ = SchemaErrorType.HTTP_SERVER_ERROR
47
+ elif status_code >= 400:
48
+ message = f"Failed to load schema due to client error (HTTP {status_code} {reason})"
49
+ if status_code == 403:
50
+ type_ = SchemaErrorType.HTTP_FORBIDDEN
51
+ elif status_code == 404:
52
+ type_ = SchemaErrorType.HTTP_NOT_FOUND
53
+ else:
54
+ type_ = SchemaErrorType.HTTP_CLIENT_ERROR
55
+ else:
56
+ return None
57
+ raise SchemaError(message=message, type=type_, url=response.request.url, response=response, extras=[])