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
schemathesis/_compat.py CHANGED
@@ -1,7 +1,6 @@
1
1
  import hypothesis
2
2
  import hypothesis_jsonschema._from_schema
3
3
  import jsonschema
4
- import werkzeug
5
4
  from hypothesis import strategies as st
6
5
  from hypothesis.errors import InvalidArgument
7
6
  from packaging import version
@@ -11,7 +10,9 @@ try:
11
10
  except ImportError:
12
11
  import importlib_metadata as metadata # type: ignore
13
12
 
14
- if version.parse(werkzeug.__version__) < version.parse("2.1.0"):
13
+ WERKZEUG_VERSION = version.parse(metadata.version("werkzeug"))
14
+ IS_WERKZEUG_ABOVE_3 = WERKZEUG_VERSION >= version.parse("3.0")
15
+ if WERKZEUG_VERSION < version.parse("2.1.0"):
15
16
  from werkzeug.wrappers.json import JSONMixin
16
17
  else:
17
18
 
@@ -12,7 +12,7 @@ from hypothesis_jsonschema._canonicalise import HypothesisRefResolutionError
12
12
 
13
13
  from .auths import get_auth_storage_from_test
14
14
  from .constants import DEFAULT_DEADLINE, DataGenerationMethod
15
- from .exceptions import InvalidSchema
15
+ from .exceptions import OperationSchemaError
16
16
  from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher
17
17
  from .models import APIOperation, Case
18
18
  from .utils import GivenInput, combine_strategies
@@ -63,16 +63,19 @@ def create_test(
63
63
  setup_default_deadline(wrapped_test)
64
64
  if settings is not None:
65
65
  wrapped_test = settings(wrapped_test)
66
- existing_settings = getattr(wrapped_test, "_hypothesis_internal_use_settings", None)
67
- if existing_settings and Phase.explicit in existing_settings.phases:
68
- wrapped_test = add_examples(wrapped_test, operation, hook_dispatcher=hook_dispatcher)
66
+ existing_settings = _get_hypothesis_settings(wrapped_test)
67
+ if existing_settings is not None:
68
+ existing_settings = remove_explain_phase(existing_settings)
69
+ wrapped_test._hypothesis_internal_use_settings = existing_settings # type: ignore
70
+ if Phase.explicit in existing_settings.phases:
71
+ wrapped_test = add_examples(wrapped_test, operation, hook_dispatcher=hook_dispatcher)
69
72
  return wrapped_test
70
73
 
71
74
 
72
75
  def setup_default_deadline(wrapped_test: Callable) -> None:
73
76
  # Quite hacky, but it is the simplest way to set up the default deadline value without affecting non-Schemathesis
74
77
  # tests globally
75
- existing_settings = getattr(wrapped_test, "_hypothesis_internal_use_settings", None)
78
+ existing_settings = _get_hypothesis_settings(wrapped_test)
76
79
  if existing_settings is not None and existing_settings.deadline == hypothesis.settings.default.deadline:
77
80
  with warnings.catch_warnings():
78
81
  warnings.simplefilter("ignore", HypothesisWarning)
@@ -80,6 +83,18 @@ def setup_default_deadline(wrapped_test: Callable) -> None:
80
83
  wrapped_test._hypothesis_internal_use_settings = new_settings # type: ignore
81
84
 
82
85
 
86
+ def remove_explain_phase(settings: hypothesis.settings) -> hypothesis.settings:
87
+ # The "explain" phase is not supported
88
+ if Phase.explain in settings.phases:
89
+ phases = tuple(phase for phase in settings.phases if phase != Phase.explain)
90
+ return hypothesis.settings(settings, phases=phases)
91
+ return settings
92
+
93
+
94
+ def _get_hypothesis_settings(test: Callable) -> Optional[hypothesis.settings]:
95
+ return getattr(test, "_hypothesis_internal_use_settings", None)
96
+
97
+
83
98
  def make_async_test(test: Callable) -> Callable:
84
99
  def async_run(*args: Any, **kwargs: Any) -> None:
85
100
  loop = asyncio.get_event_loop()
@@ -94,7 +109,7 @@ def add_examples(test: Callable, operation: APIOperation, hook_dispatcher: Optio
94
109
  """Add examples to the Hypothesis test, if they are specified in the schema."""
95
110
  try:
96
111
  examples: List[Case] = [get_single_example(strategy) for strategy in operation.get_strategies_from_examples()]
97
- except (InvalidSchema, HypothesisRefResolutionError, Unsatisfiable):
112
+ except (OperationSchemaError, HypothesisRefResolutionError, Unsatisfiable):
98
113
  # Invalid schema:
99
114
  # In this case, the user didn't pass `--validate-schema=false` and see an error in the output anyway,
100
115
  # and no tests will be executed. For this reason, examples can be skipped
schemathesis/_xml.py ADDED
@@ -0,0 +1,177 @@
1
+ """XML serialization."""
2
+ from io import StringIO
3
+ from typing import Any, Dict, List, Union
4
+ from xml.etree import ElementTree
5
+
6
+ from hypothesis import reject
7
+
8
+ from .exceptions import UnboundPrefixError
9
+ from .utils import fast_deepcopy
10
+
11
+ Primitive = Union[str, int, float, bool, None]
12
+ JSON = Union[Primitive, List, Dict[str, Any]]
13
+ DEFAULT_TAG_NAME = "data"
14
+ NAMESPACE_URL = "http://example.com/schema"
15
+
16
+
17
+ def _to_xml(value: Any, raw_schema: Dict[str, Any], resolved_schema: Dict[str, Any]) -> Dict[str, Any]:
18
+ """Serialize a generated Python object as an XML string.
19
+
20
+ Schemas may contain additional information for fine-tuned XML serialization.
21
+
22
+ :param value: Generated value
23
+ :param raw_schema: The payload definition with not resolved references.
24
+ :param resolved_schema: The payload definition with all references resolved.
25
+ """
26
+ if isinstance(value, (bytes, str)):
27
+ return {"data": value}
28
+ tag = _get_xml_tag(raw_schema, resolved_schema)
29
+ buffer = StringIO()
30
+ # Collect all namespaces to ensure that all child nodes with prefixes have proper namespaces in their parent nodes
31
+ namespace_stack: List[str] = []
32
+ _write_xml(buffer, value, tag, resolved_schema, namespace_stack)
33
+ data = buffer.getvalue()
34
+ if not is_valid_xml(data):
35
+ reject()
36
+ return {"data": data.encode("utf8")}
37
+
38
+
39
+ _from_string = ElementTree.fromstring
40
+
41
+
42
+ def is_valid_xml(data: str) -> bool:
43
+ try:
44
+ _from_string(f"<root xmlns:smp='{NAMESPACE_URL}'>{data}</root>")
45
+ return True
46
+ except ElementTree.ParseError:
47
+ return False
48
+
49
+
50
+ def _get_xml_tag(raw_schema: Dict[str, Any], resolved_schema: Dict[str, Any]) -> str:
51
+ # On the top level we need to detect the proper XML tag, in other cases it is known from object properties
52
+ if resolved_schema.get("xml", {}).get("name"):
53
+ return resolved_schema["xml"]["name"]
54
+
55
+ # Check if the name can be derived from a reference in the raw schema
56
+ if "$ref" in raw_schema:
57
+ return _get_tag_name_from_reference(raw_schema["$ref"])
58
+
59
+ # Here we don't have any name for the payload schema - no reference or the `xml` property
60
+ return DEFAULT_TAG_NAME
61
+
62
+
63
+ def _write_xml(buffer: StringIO, value: JSON, tag: str, schema: Dict[str, Any], namespace_stack: List[str]) -> None:
64
+ if isinstance(value, dict):
65
+ _write_object(buffer, value, tag, schema, namespace_stack)
66
+ elif isinstance(value, list):
67
+ _write_array(buffer, value, tag, schema, namespace_stack)
68
+ else:
69
+ _write_primitive(buffer, value, tag, schema, namespace_stack)
70
+
71
+
72
+ def _validate_prefix(options: Dict[str, Any], namespace_stack: List[str]) -> None:
73
+ try:
74
+ prefix = options["prefix"]
75
+ if prefix not in namespace_stack:
76
+ raise UnboundPrefixError(prefix)
77
+ except KeyError:
78
+ pass
79
+
80
+
81
+ def push_namespace_if_any(namespace_stack: List[str], options: Dict[str, Any]) -> None:
82
+ if "namespace" in options and "prefix" in options:
83
+ namespace_stack.append(options["prefix"])
84
+
85
+
86
+ def pop_namespace_if_any(namespace_stack: List[str], options: Dict[str, Any]) -> None:
87
+ if "namespace" in options and "prefix" in options:
88
+ namespace_stack.pop()
89
+
90
+
91
+ def _write_object(buffer: StringIO, obj: Dict[str, JSON], tag: str, schema: Dict[str, Any], stack: List[str]) -> None:
92
+ options = schema.get("xml", {})
93
+ push_namespace_if_any(stack, options)
94
+ if "prefix" in options:
95
+ tag = f"{options['prefix']}:{tag}"
96
+ buffer.write(f"<{tag}")
97
+ if "namespace" in options:
98
+ _write_namespace(buffer, options)
99
+ attributes = []
100
+ children_buffer = StringIO()
101
+ properties = schema.get("properties", {})
102
+ for child_name, value in obj.items():
103
+ property_schema = properties.get(child_name, {})
104
+ child_options = property_schema.get("xml", {})
105
+ push_namespace_if_any(stack, child_options)
106
+ child_tag = child_options.get("name", child_name)
107
+ if child_options.get("prefix"):
108
+ _validate_prefix(child_options, stack)
109
+ prefix = child_options["prefix"]
110
+ child_tag = f"{prefix}:{child_tag}"
111
+ if child_options.get("attribute", False):
112
+ attributes.append(f'{child_tag}="{value}"')
113
+ continue
114
+ _write_xml(children_buffer, value, child_tag, property_schema, stack)
115
+ pop_namespace_if_any(stack, child_options)
116
+
117
+ if attributes:
118
+ buffer.write(f" {' '.join(attributes)}")
119
+ buffer.write(">")
120
+ buffer.write(children_buffer.getvalue())
121
+ buffer.write(f"</{tag}>")
122
+ pop_namespace_if_any(stack, options)
123
+
124
+
125
+ def _write_array(buffer: StringIO, obj: List[JSON], tag: str, schema: Dict[str, Any], stack: List[str]) -> None:
126
+ options = schema.get("xml", {})
127
+ push_namespace_if_any(stack, options)
128
+ if options.get("prefix"):
129
+ tag = f"{options['prefix']}:{tag}"
130
+ wrapped = options.get("wrapped", False)
131
+ is_namespace_specified = False
132
+ if wrapped:
133
+ buffer.write(f"<{tag}")
134
+ if "namespace" in options:
135
+ is_namespace_specified = True
136
+ _write_namespace(buffer, options)
137
+ buffer.write(">")
138
+ # In Open API `items` value should be an object and not an array
139
+ items = fast_deepcopy(schema.get("items", {}))
140
+ child_options = items.get("xml", {})
141
+ child_tag = child_options.get("name", tag)
142
+ if not is_namespace_specified and "namespace" in options:
143
+ child_options.setdefault("namespace", options["namespace"])
144
+ if "prefix" in options:
145
+ child_options.setdefault("prefix", options["prefix"])
146
+ items["xml"] = child_options
147
+ _validate_prefix(child_options, stack)
148
+ for item in obj:
149
+ _write_xml(buffer, item, child_tag, items, stack)
150
+ if wrapped:
151
+ buffer.write(f"</{tag}>")
152
+ pop_namespace_if_any(stack, options)
153
+
154
+
155
+ def _write_primitive(
156
+ buffer: StringIO, obj: Primitive, tag: str, schema: Dict[str, Any], namespace_stack: List[str]
157
+ ) -> None:
158
+ xml_options = schema.get("xml", {})
159
+ # There is no need for modifying the namespace stack, as we know that this function is terminal - it do not recurse
160
+ # and this element don't have any children. Therefore, checking the prefix is enough
161
+ _validate_prefix(xml_options, namespace_stack)
162
+ buffer.write(f"<{tag}")
163
+ if "namespace" in xml_options:
164
+ _write_namespace(buffer, xml_options)
165
+ buffer.write(f">{obj}</{tag}>")
166
+
167
+
168
+ def _write_namespace(buffer: StringIO, options: Dict[str, Any]) -> None:
169
+ buffer.write(" xmlns")
170
+ if "prefix" in options:
171
+ buffer.write(f":{options['prefix']}")
172
+ buffer.write(f'="{options["namespace"]}"')
173
+
174
+
175
+ def _get_tag_name_from_reference(reference: str) -> str:
176
+ """Extract object name from a reference."""
177
+ return reference.rsplit("/", maxsplit=1)[1]
schemathesis/auths.py CHANGED
@@ -1,8 +1,10 @@
1
1
  """Support for custom API authentication mechanisms."""
2
+ import inspect
2
3
  import threading
3
4
  import time
5
+ import warnings
4
6
  from dataclasses import dataclass, field
5
- from typing import TYPE_CHECKING, Any, Callable, Generic, List, Optional, Type, TypeVar, Union
7
+ from typing import TYPE_CHECKING, Any, Callable, Generic, List, Optional, Type, TypeVar, Union, overload
6
8
 
7
9
  import requests.auth
8
10
  from typing_extensions import Protocol, runtime_checkable
@@ -32,12 +34,13 @@ class AuthContext:
32
34
 
33
35
 
34
36
  @runtime_checkable
35
- class AuthProvider(Protocol):
37
+ class AuthProvider(Generic[Auth], Protocol):
36
38
  """Get authentication data for an API and set it on the generated test cases."""
37
39
 
38
- def get(self, context: AuthContext) -> Optional[Auth]:
40
+ def get(self, case: "Case", context: AuthContext) -> Optional[Auth]:
39
41
  """Get the authentication data.
40
42
 
43
+ :param Case case: Generated test case.
41
44
  :param AuthContext context: Holds state relevant for the authentication process.
42
45
  :return: Any authentication data you find useful for your use case. For example, it could be an access token.
43
46
  """
@@ -65,7 +68,7 @@ class RequestsAuth(Generic[Auth]):
65
68
 
66
69
  auth: requests.auth.AuthBase
67
70
 
68
- def get(self, _: AuthContext) -> Optional[Auth]:
71
+ def get(self, _: "Case", __: AuthContext) -> Optional[Auth]:
69
72
  return self.auth # type: ignore[return-value]
70
73
 
71
74
  def set(self, case: "Case", _: Auth, __: AuthContext) -> None:
@@ -83,7 +86,7 @@ class CachingAuthProvider(Generic[Auth]):
83
86
  timer: Callable[[], float] = time.monotonic
84
87
  _refresh_lock: threading.Lock = field(default_factory=threading.Lock)
85
88
 
86
- def get(self, context: AuthContext) -> Optional[Auth]:
89
+ def get(self, case: "Case", context: AuthContext) -> Optional[Auth]:
87
90
  """Get cached auth value."""
88
91
  if self.cache_entry is None or self.timer() >= self.cache_entry.expires:
89
92
  with self._refresh_lock:
@@ -91,7 +94,7 @@ class CachingAuthProvider(Generic[Auth]):
91
94
  # Another thread updated the cache
92
95
  return self.cache_entry.data
93
96
  # We know that optional auth is possible only inside a higher-level wrapper
94
- data: Auth = self.provider.get(context) # type: ignore[assignment]
97
+ data: Auth = _provider_get(self.provider, case, context) # type: ignore[assignment]
95
98
  self.cache_entry = CacheEntry(data=data, expires=self.timer() + self.refresh_interval)
96
99
  return data
97
100
  return self.cache_entry.data
@@ -207,9 +210,9 @@ class SelectiveAuthProvider(Generic[Auth]):
207
210
  provider: AuthProvider
208
211
  filter_set: FilterSet
209
212
 
210
- def get(self, context: AuthContext) -> Optional[Auth]:
213
+ def get(self, case: "Case", context: AuthContext) -> Optional[Auth]:
211
214
  if self.filter_set.match(context):
212
- return self.provider.get(context)
215
+ return _provider_get(self.provider, case, context)
213
216
  return None
214
217
 
215
218
  def set(self, case: "Case", data: Auth, context: AuthContext) -> None:
@@ -227,6 +230,23 @@ class AuthStorage(Generic[Auth]):
227
230
  """Whether there is an auth provider set."""
228
231
  return bool(self.providers)
229
232
 
233
+ @overload
234
+ def __call__(
235
+ self,
236
+ *,
237
+ refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL,
238
+ ) -> FilterableRegisterAuth:
239
+ pass
240
+
241
+ @overload
242
+ def __call__(
243
+ self,
244
+ provider_class: Type[AuthProvider],
245
+ *,
246
+ refresh_interval: Optional[int] = DEFAULT_REFRESH_INTERVAL,
247
+ ) -> FilterableApplyAuth:
248
+ pass
249
+
230
250
  def __call__(
231
251
  self,
232
252
  provider_class: Optional[Type[AuthProvider]] = None,
@@ -281,7 +301,6 @@ class AuthStorage(Generic[Auth]):
281
301
  @schemathesis.auth()
282
302
  class TokenAuth:
283
303
  def get(self, context):
284
- # This is a real endpoint, try it out!
285
304
  response = requests.post(
286
305
  "https://example.schemathesis.io/api/token/",
287
306
  json={"username": "demo", "password": "test"},
@@ -359,12 +378,31 @@ class AuthStorage(Generic[Auth]):
359
378
  if not self.is_defined:
360
379
  raise UsageError("No auth provider is defined.")
361
380
  for provider in self.providers:
362
- data: Optional[Auth] = provider.get(context)
381
+ data: Optional[Auth] = _provider_get(provider, case, context)
363
382
  if data is not None:
364
383
  provider.set(case, data, context)
365
384
  break
366
385
 
367
386
 
387
+ def _provider_get(auth_provider: AuthProvider, case: "Case", context: AuthContext) -> Optional[Auth]:
388
+ # A shim to provide a compatibility layer between previously used convention for `AuthProvider.get`
389
+ # where it used to accept a single `context` argument
390
+ method = auth_provider.get
391
+ parameters = inspect.signature(method).parameters
392
+ if len(parameters) == 1:
393
+ # Old calling convention
394
+ warnings.warn(
395
+ "The method 'get' of your AuthProvider is using the old calling convention, "
396
+ "which is deprecated and will be removed in Schemathesis 4.0. "
397
+ "Please update it to accept both 'case' and 'context' as arguments.",
398
+ DeprecationWarning,
399
+ stacklevel=1,
400
+ )
401
+ return method(context) # type: ignore
402
+ # New calling convention
403
+ return method(case, context)
404
+
405
+
368
406
  def set_on_case(case: "Case", context: AuthContext, auth_storage: Optional[AuthStorage]) -> None:
369
407
  """Set authentication data on this case.
370
408
 
@@ -3,7 +3,9 @@ import enum
3
3
  import os
4
4
  import sys
5
5
  import traceback
6
+ import warnings
6
7
  from collections import defaultdict
8
+ from contextlib import suppress
7
9
  from dataclasses import dataclass
8
10
  from enum import Enum
9
11
  from queue import Queue
@@ -14,12 +16,14 @@ import click
14
16
  import hypothesis
15
17
  import requests
16
18
  import yaml
19
+ from urllib3.exceptions import InsecureRequestWarning
17
20
 
18
21
  from .. import checks as checks_module
19
- from .. import contrib
22
+ from .. import contrib, experimental
20
23
  from .. import fixups as _fixups
21
24
  from .. import runner, service
22
25
  from .. import targets as targets_module
26
+ from ..code_samples import CodeSampleStyle
23
27
  from ..constants import (
24
28
  API_NAME_ENV_VAR,
25
29
  BASE_URL_ENV_VAR,
@@ -29,10 +33,9 @@ from ..constants import (
29
33
  HOOKS_MODULE_ENV_VAR,
30
34
  HYPOTHESIS_IN_MEMORY_DATABASE_IDENTIFIER,
31
35
  WAIT_FOR_SCHEMA_ENV_VAR,
32
- CodeSampleStyle,
33
36
  DataGenerationMethod,
34
37
  )
35
- from ..exceptions import HTTPError, SchemaLoadingError
38
+ from ..exceptions import SchemaError
36
39
  from ..fixups import ALL_FIXUPS
37
40
  from ..hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, HookScope
38
41
  from ..models import Case, CheckFunction
@@ -52,6 +55,7 @@ from .debug import DebugOutputHandler
52
55
  from .handlers import EventHandler
53
56
  from .junitxml import JunitXMLHandler
54
57
  from .options import CsvChoice, CsvEnumChoice, CustomHelpMessageChoice, NotSet, OptionalInt
58
+ from .sanitization import SanitizationHandler
55
59
 
56
60
  try:
57
61
  from yaml import CSafeLoader as SafeLoader
@@ -85,6 +89,7 @@ DEPRECATED_PRE_RUN_OPTION_WARNING = (
85
89
  f"Use the `{HOOKS_MODULE_ENV_VAR}` environment variable instead"
86
90
  )
87
91
  CASSETTES_PATH_INVALID_USAGE_MESSAGE = "Can't use `--store-network-log` and `--cassette-path` simultaneously"
92
+ COLOR_OPTIONS_INVALID_USAGE_MESSAGE = "Can't use `--no-color` and `--force-color` simultaneously"
88
93
 
89
94
 
90
95
  def reset_checks() -> None:
@@ -497,6 +502,13 @@ The report data, consisting of a tar gz file with multiple JSON files, is subjec
497
502
  help="Force Schemathesis to parse the input schema with the specified spec version.",
498
503
  type=click.Choice(["20", "30"]),
499
504
  )
505
+ @click.option(
506
+ "--sanitize-output",
507
+ type=bool,
508
+ default=True,
509
+ show_default=True,
510
+ help="Enable or disable automatic output sanitization to obscure sensitive data.",
511
+ )
500
512
  @click.option(
501
513
  "--contrib-unique-data",
502
514
  "contrib_unique_data",
@@ -586,6 +598,14 @@ The report data, consisting of a tar gz file with multiple JSON files, is subjec
586
598
  group=ParameterGroup.hypothesis,
587
599
  )
588
600
  @click.option("--no-color", help="Disable ANSI color escape codes.", type=bool, is_flag=True)
601
+ @click.option("--force-color", help="Explicitly tells to enable ANSI color escape codes.", type=bool, is_flag=True)
602
+ @click.option(
603
+ "--experimental",
604
+ help="Enable experimental support for specific features.",
605
+ type=click.Choice([experimental.OPEN_API_3_1.name]),
606
+ callback=callbacks.convert_experimental,
607
+ multiple=True,
608
+ )
589
609
  @click.option(
590
610
  "--schemathesis-io-token",
591
611
  help="Schemathesis.io authentication token.",
@@ -618,6 +638,7 @@ def run(
618
638
  auth: Optional[Tuple[str, str]],
619
639
  auth_type: str,
620
640
  headers: Dict[str, str],
641
+ experimental: list,
621
642
  checks: Iterable[str] = DEFAULT_CHECKS_NAMES,
622
643
  exclude_checks: Iterable[str] = [],
623
644
  data_generation_methods: Tuple[DataGenerationMethod, ...] = DEFAULT_DATA_GENERATION_METHODS,
@@ -652,6 +673,7 @@ def run(
652
673
  stateful: Optional[Stateful] = None,
653
674
  stateful_recursion_limit: int = DEFAULT_STATEFUL_RECURSION_LIMIT,
654
675
  force_schema_version: Optional[str] = None,
676
+ sanitize_output: bool = True,
655
677
  contrib_unique_data: bool = False,
656
678
  contrib_openapi_formats_uuid: bool = False,
657
679
  hypothesis_database: Optional[str] = None,
@@ -670,6 +692,7 @@ def run(
670
692
  schemathesis_io_url: str = service.DEFAULT_URL,
671
693
  schemathesis_io_telemetry: bool = True,
672
694
  hosts_file: PathLike = service.DEFAULT_HOSTS_PATH,
695
+ force_color: bool = False,
673
696
  ) -> None:
674
697
  """Perform schemathesis test against an API specified by SCHEMA.
675
698
 
@@ -677,6 +700,10 @@ def run(
677
700
 
678
701
  API_NAME is an API identifier to upload data to Schemathesis.io.
679
702
  """
703
+ # Enable selected experiments
704
+ for experiment in experimental:
705
+ experiment.enable()
706
+
680
707
  report: Optional[Union[ReportToService, click.utils.LazyFile]]
681
708
  if report_value is None:
682
709
  report = None
@@ -685,7 +712,12 @@ def run(
685
712
  else:
686
713
  report = REPORT_TO_SERVICE
687
714
  started_at = current_datetime()
688
- maybe_disable_color(ctx, no_color)
715
+
716
+ if no_color and force_color:
717
+ error_message(COLOR_OPTIONS_INVALID_USAGE_MESSAGE)
718
+ sys.exit(1)
719
+ decide_color_output(ctx, no_color, force_color)
720
+
689
721
  check_auth(auth, headers)
690
722
  selected_targets = tuple(target for target in targets_module.ALL_TARGETS if target.__name__ in targets)
691
723
 
@@ -806,6 +838,7 @@ def run(
806
838
  workers_num=workers_num,
807
839
  rate_limit=rate_limit,
808
840
  show_errors_tracebacks=show_errors_tracebacks,
841
+ wait_for_schema=wait_for_schema,
809
842
  validate_schema=validate_schema,
810
843
  cassette_path=cassette_path,
811
844
  cassette_preserve_exact_body_bytes=cassette_preserve_exact_body_bytes,
@@ -814,6 +847,7 @@ def run(
814
847
  code_sample_style=code_sample_style,
815
848
  data_generation_methods=data_generation_methods,
816
849
  debug_output_file=debug_output_file,
850
+ sanitize_output=sanitize_output,
817
851
  host_data=host_data,
818
852
  client=client,
819
853
  report=report,
@@ -944,8 +978,10 @@ def into_event_stream(
944
978
  stateful_recursion_limit=stateful_recursion_limit,
945
979
  hypothesis_settings=hypothesis_settings,
946
980
  ).execute()
981
+ except SchemaError as error:
982
+ yield events.InternalError.from_schema_error(error)
947
983
  except Exception as exc:
948
- yield events.InternalError.from_exc(exc, wait_for_schema)
984
+ yield events.InternalError.from_exc(exc)
949
985
 
950
986
 
951
987
  def load_schema(config: LoaderConfig) -> BaseSchema:
@@ -961,17 +997,24 @@ def load_schema(config: LoaderConfig) -> BaseSchema:
961
997
  return _try_load_schema(config, first, second)
962
998
 
963
999
 
1000
+ def should_try_more(exc: SchemaError) -> bool:
1001
+ # We should not try other loaders for cases when we can't even establish connection
1002
+ return not isinstance(exc.__cause__, requests.exceptions.ConnectionError)
1003
+
1004
+
964
1005
  def _try_load_schema(
965
1006
  config: LoaderConfig, first: Callable[[LoaderConfig], BaseSchema], second: Callable[[LoaderConfig], BaseSchema]
966
1007
  ) -> BaseSchema:
967
- try:
968
- return first(config)
969
- except (HTTPError, SchemaLoadingError) as exc:
1008
+ with warnings.catch_warnings():
1009
+ warnings.simplefilter("ignore", InsecureRequestWarning)
970
1010
  try:
971
- return second(config)
972
- except (HTTPError, SchemaLoadingError):
973
- # Raise the first loader's error
974
- raise exc from None
1011
+ return first(config)
1012
+ except SchemaError as exc:
1013
+ if should_try_more(exc):
1014
+ with suppress(Exception):
1015
+ return second(config)
1016
+ # Re-raise the original error
1017
+ raise exc
975
1018
 
976
1019
 
977
1020
  def _load_graphql_schema(config: LoaderConfig) -> GraphQLSchema:
@@ -1095,6 +1138,7 @@ def execute(
1095
1138
  workers_num: int,
1096
1139
  rate_limit: Optional[str],
1097
1140
  show_errors_tracebacks: bool,
1141
+ wait_for_schema: Optional[float],
1098
1142
  validate_schema: bool,
1099
1143
  cassette_path: Optional[click.utils.LazyFile],
1100
1144
  cassette_preserve_exact_body_bytes: bool,
@@ -1103,6 +1147,7 @@ def execute(
1103
1147
  code_sample_style: CodeSampleStyle,
1104
1148
  data_generation_methods: Tuple[DataGenerationMethod, ...],
1105
1149
  debug_output_file: Optional[click.utils.LazyFile],
1150
+ sanitize_output: bool,
1106
1151
  host_data: service.hosts.HostData,
1107
1152
  client: Optional[service.ServiceClient],
1108
1153
  report: Optional[Union[ReportToService, click.utils.LazyFile]],
@@ -1156,11 +1201,14 @@ def execute(
1156
1201
  cassettes.CassetteWriter(cassette_path, preserve_exact_body_bytes=cassette_preserve_exact_body_bytes)
1157
1202
  )
1158
1203
  handlers.append(get_output_handler(workers_num))
1204
+ if sanitize_output:
1205
+ handlers.insert(0, SanitizationHandler())
1159
1206
  execution_context = ExecutionContext(
1160
1207
  hypothesis_settings=hypothesis_settings,
1161
1208
  workers_num=workers_num,
1162
1209
  rate_limit=rate_limit,
1163
1210
  show_errors_tracebacks=show_errors_tracebacks,
1211
+ wait_for_schema=wait_for_schema,
1164
1212
  validate_schema=validate_schema,
1165
1213
  cassette_path=cassette_path.name if cassette_path is not None else None,
1166
1214
  junit_xml_file=junit_xml.name if junit_xml is not None else None,
@@ -1195,9 +1243,10 @@ def execute(
1195
1243
 
1196
1244
 
1197
1245
  def handle_service_error(exc: requests.HTTPError, api_name: str) -> NoReturn:
1198
- if exc.response.status_code == 403:
1199
- error_message(exc.response.json()["detail"])
1200
- elif exc.response.status_code == 404:
1246
+ response = cast(requests.Response, exc.response)
1247
+ if response.status_code == 403:
1248
+ error_message(response.json()["detail"])
1249
+ elif response.status_code == 404:
1201
1250
  error_message(f"API with name `{api_name}` not found!")
1202
1251
  else:
1203
1252
  output.default.display_service_error(service.Error(exc))
@@ -1221,6 +1270,7 @@ def get_exit_code(event: events.ExecutionEvent) -> int:
1221
1270
  @click.option("--uri", help="A regexp that filters interactions by their request URI.", type=str)
1222
1271
  @click.option("--method", help="A regexp that filters interactions by their request method.", type=str)
1223
1272
  @click.option("--no-color", help="Disable ANSI color escape codes.", type=bool, is_flag=True)
1273
+ @click.option("--force-color", help="Explicitly tells to enable ANSI color escape codes.", type=bool, is_flag=True)
1224
1274
  @click.option("--verbosity", "-v", help="Increase verbosity of the output.", count=True)
1225
1275
  @with_request_tls_verify
1226
1276
  @with_request_cert
@@ -1238,13 +1288,18 @@ def replay(
1238
1288
  request_tls_verify: bool = True,
1239
1289
  request_cert: Optional[str] = None,
1240
1290
  request_cert_key: Optional[str] = None,
1291
+ force_color: bool = False,
1241
1292
  ) -> None:
1242
1293
  """Replay a cassette.
1243
1294
 
1244
1295
  Cassettes in VCR-compatible format can be replayed.
1245
1296
  For example, ones that are recorded with ``store-network-log`` option of `st run` command.
1246
1297
  """
1247
- maybe_disable_color(ctx, no_color)
1298
+ if no_color and force_color:
1299
+ error_message(COLOR_OPTIONS_INVALID_USAGE_MESSAGE)
1300
+ sys.exit(1)
1301
+ decide_color_output(ctx, no_color, force_color)
1302
+
1248
1303
  click.secho(f"{bold('Replaying cassette')}: {cassette_path}")
1249
1304
  with open(cassette_path, "rb") as fd:
1250
1305
  cassette = yaml.load(fd, Loader=SafeLoader)
@@ -1313,7 +1368,8 @@ def login(token: str, hostname: str, hosts_file: str, protocol: str, request_tls
1313
1368
  service.hosts.store(token, hostname, hosts_file)
1314
1369
  success_message(f"Logged in into {hostname} as " + bold(username))
1315
1370
  except requests.HTTPError as exc:
1316
- detail = exc.response.json()["detail"]
1371
+ response = cast(requests.Response, exc.response)
1372
+ detail = response.json()["detail"]
1317
1373
  error_message(f"Failed to login into {hostname}: " + bold(detail))
1318
1374
  sys.exit(1)
1319
1375
 
@@ -1358,8 +1414,10 @@ def bold(message: str) -> str:
1358
1414
  return click.style(message, bold=True)
1359
1415
 
1360
1416
 
1361
- def maybe_disable_color(ctx: click.Context, no_color: bool) -> None:
1362
- if no_color or "NO_COLOR" in os.environ:
1417
+ def decide_color_output(ctx: click.Context, no_color: bool, force_color: bool) -> None:
1418
+ if force_color:
1419
+ ctx.color = True
1420
+ elif no_color or "NO_COLOR" in os.environ:
1363
1421
  ctx.color = False
1364
1422
 
1365
1423