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.
- schemathesis/_compat.py +3 -2
- schemathesis/_hypothesis.py +21 -6
- schemathesis/_xml.py +177 -0
- schemathesis/auths.py +48 -10
- schemathesis/cli/__init__.py +77 -19
- schemathesis/cli/callbacks.py +42 -18
- schemathesis/cli/context.py +2 -1
- schemathesis/cli/output/default.py +102 -34
- schemathesis/cli/sanitization.py +15 -0
- schemathesis/code_samples.py +141 -0
- schemathesis/constants.py +1 -24
- schemathesis/exceptions.py +127 -26
- schemathesis/experimental/__init__.py +85 -0
- schemathesis/extra/pytest_plugin.py +10 -4
- schemathesis/fixups/__init__.py +8 -2
- schemathesis/fixups/fast_api.py +11 -1
- schemathesis/fixups/utf8_bom.py +7 -1
- schemathesis/hooks.py +63 -0
- schemathesis/lazy.py +10 -4
- schemathesis/loaders.py +57 -0
- schemathesis/models.py +120 -96
- schemathesis/parameters.py +3 -0
- schemathesis/runner/__init__.py +3 -0
- schemathesis/runner/events.py +55 -20
- schemathesis/runner/impl/core.py +54 -54
- schemathesis/runner/serialization.py +75 -34
- schemathesis/sanitization.py +248 -0
- schemathesis/schemas.py +21 -6
- schemathesis/serializers.py +32 -3
- schemathesis/service/serialization.py +5 -1
- schemathesis/specs/graphql/loaders.py +44 -13
- schemathesis/specs/graphql/schemas.py +56 -25
- schemathesis/specs/openapi/_hypothesis.py +11 -23
- schemathesis/specs/openapi/definitions.py +572 -0
- schemathesis/specs/openapi/loaders.py +100 -49
- schemathesis/specs/openapi/parameters.py +2 -2
- schemathesis/specs/openapi/schemas.py +87 -13
- schemathesis/specs/openapi/security.py +1 -0
- schemathesis/stateful.py +2 -2
- schemathesis/utils.py +30 -9
- schemathesis-3.20.1.dist-info/METADATA +342 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/RECORD +45 -39
- schemathesis-3.19.7.dist-info/METADATA +0 -291
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.19.7.dist-info → schemathesis-3.20.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/cli/callbacks.py
CHANGED
|
@@ -10,17 +10,37 @@ import hypothesis
|
|
|
10
10
|
from click.types import LazyFile # type: ignore
|
|
11
11
|
from requests import PreparedRequest, RequestException
|
|
12
12
|
|
|
13
|
-
from .. import exceptions, throttling, utils
|
|
14
|
-
from ..
|
|
13
|
+
from .. import exceptions, experimental, throttling, utils
|
|
14
|
+
from ..code_samples import CodeSampleStyle
|
|
15
|
+
from ..constants import DataGenerationMethod
|
|
15
16
|
from ..service.hosts import get_temporary_hosts_file
|
|
16
17
|
from ..stateful import Stateful
|
|
17
18
|
from ..types import PathLike
|
|
18
19
|
from .constants import DEFAULT_WORKERS
|
|
19
20
|
|
|
21
|
+
INVALID_DERANDOMIZE_MESSAGE = (
|
|
22
|
+
"`--hypothesis-derandomize` implies no database, so passing `--hypothesis-database` too is invalid."
|
|
23
|
+
)
|
|
20
24
|
MISSING_CASSETTE_PATH_ARGUMENT_MESSAGE = (
|
|
21
25
|
'Missing argument, "--cassette-path" should be specified as well if you use "--cassette-preserve-exact-body-bytes".'
|
|
22
26
|
)
|
|
23
27
|
INVALID_SCHEMA_MESSAGE = "Invalid SCHEMA, must be a valid URL, file path or an API name from Schemathesis.io."
|
|
28
|
+
FILE_DOES_NOT_EXIST_MESSAGE = "The specified file does not exist. Please provide a valid path to an existing file."
|
|
29
|
+
INVALID_BASE_URL_MESSAGE = (
|
|
30
|
+
"The provided base URL is invalid. This URL serves as a prefix for all API endpoints you want to test. "
|
|
31
|
+
"Make sure it is a properly formatted URL."
|
|
32
|
+
)
|
|
33
|
+
MISSING_BASE_URL_MESSAGE = "The `--base-url` option is required when specifying a schema via a file."
|
|
34
|
+
APPLICATION_FORMAT_MESSAGE = """Unable to import application from the provided module.
|
|
35
|
+
The `--app` option should follow this format:
|
|
36
|
+
|
|
37
|
+
module_path:variable_name
|
|
38
|
+
|
|
39
|
+
- `module_path`: A path to an importable Python module.
|
|
40
|
+
- `variable_name`: The name of the application variable within that module.
|
|
41
|
+
|
|
42
|
+
Example: `st run --app=your_module:app ...`"""
|
|
43
|
+
MISSING_REQUEST_CERT_MESSAGE = "The `--request-cert` option must be specified if `--request-cert-key` is used."
|
|
24
44
|
|
|
25
45
|
|
|
26
46
|
@enum.unique
|
|
@@ -47,7 +67,7 @@ def parse_schema_kind(schema: str, app: Optional[str]) -> SchemaInputKind:
|
|
|
47
67
|
raise click.UsageError(INVALID_SCHEMA_MESSAGE)
|
|
48
68
|
if netloc:
|
|
49
69
|
return SchemaInputKind.URL
|
|
50
|
-
if utils.file_exists(schema):
|
|
70
|
+
if utils.file_exists(schema) or utils.is_filename(schema):
|
|
51
71
|
return SchemaInputKind.PATH
|
|
52
72
|
if app is not None:
|
|
53
73
|
return SchemaInputKind.APP_PATH
|
|
@@ -69,7 +89,11 @@ def validate_schema(
|
|
|
69
89
|
if kind == SchemaInputKind.PATH:
|
|
70
90
|
# Base URL is required if it is not a dry run
|
|
71
91
|
if app is None and base_url is None and not dry_run:
|
|
72
|
-
|
|
92
|
+
if not utils.file_exists(schema):
|
|
93
|
+
message = FILE_DOES_NOT_EXIST_MESSAGE
|
|
94
|
+
else:
|
|
95
|
+
message = MISSING_BASE_URL_MESSAGE
|
|
96
|
+
raise click.UsageError(message)
|
|
73
97
|
if kind == SchemaInputKind.NAME:
|
|
74
98
|
if api_name is not None:
|
|
75
99
|
raise click.UsageError(f"Got unexpected extra argument ({api_name})")
|
|
@@ -86,9 +110,9 @@ def validate_base_url(ctx: click.core.Context, param: click.core.Parameter, raw_
|
|
|
86
110
|
try:
|
|
87
111
|
netloc = urlparse(raw_value).netloc
|
|
88
112
|
except ValueError as exc:
|
|
89
|
-
raise click.UsageError(
|
|
113
|
+
raise click.UsageError(INVALID_BASE_URL_MESSAGE) from exc
|
|
90
114
|
if raw_value and not netloc:
|
|
91
|
-
raise click.UsageError(
|
|
115
|
+
raise click.UsageError(INVALID_BASE_URL_MESSAGE)
|
|
92
116
|
return raw_value
|
|
93
117
|
|
|
94
118
|
|
|
@@ -104,14 +128,6 @@ def validate_rate_limit(
|
|
|
104
128
|
raise click.UsageError(exc.args[0]) from exc
|
|
105
129
|
|
|
106
130
|
|
|
107
|
-
APPLICATION_FORMAT_MESSAGE = (
|
|
108
|
-
"Can not import application from the given module!\n"
|
|
109
|
-
"The `--app` option value should be in format:\n\n path:variable\n\n"
|
|
110
|
-
"where `path` is an importable path to a Python module,\n"
|
|
111
|
-
"and `variable` is a variable name inside that module."
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
|
|
115
131
|
def validate_app(ctx: click.core.Context, param: click.core.Parameter, raw_value: Optional[str]) -> Optional[str]:
|
|
116
132
|
if raw_value is None:
|
|
117
133
|
return raw_value
|
|
@@ -139,9 +155,7 @@ def validate_hypothesis_database(
|
|
|
139
155
|
if raw_value is None:
|
|
140
156
|
return raw_value
|
|
141
157
|
if ctx.params.get("hypothesis_derandomize"):
|
|
142
|
-
raise click.UsageError(
|
|
143
|
-
"--hypothesis-derandomize implies no database, so passing --hypothesis-database too is invalid."
|
|
144
|
-
)
|
|
158
|
+
raise click.UsageError(INVALID_DERANDOMIZE_MESSAGE)
|
|
145
159
|
return raw_value
|
|
146
160
|
|
|
147
161
|
|
|
@@ -195,7 +209,7 @@ def validate_request_cert_key(
|
|
|
195
209
|
ctx: click.core.Context, param: click.core.Parameter, raw_value: Optional[str]
|
|
196
210
|
) -> Optional[str]:
|
|
197
211
|
if raw_value is not None and "request_cert" not in ctx.params:
|
|
198
|
-
raise click.UsageError(
|
|
212
|
+
raise click.UsageError(MISSING_REQUEST_CERT_MESSAGE)
|
|
199
213
|
return raw_value
|
|
200
214
|
|
|
201
215
|
|
|
@@ -219,6 +233,16 @@ def convert_stateful(ctx: click.core.Context, param: click.core.Parameter, value
|
|
|
219
233
|
return Stateful[value]
|
|
220
234
|
|
|
221
235
|
|
|
236
|
+
def convert_experimental(
|
|
237
|
+
ctx: click.core.Context, param: click.core.Parameter, value: Tuple[str, ...]
|
|
238
|
+
) -> List[experimental.Experiment]:
|
|
239
|
+
return [
|
|
240
|
+
feature
|
|
241
|
+
for feature in experimental.GLOBAL_EXPERIMENTS.available
|
|
242
|
+
if feature.name in value or os.getenv(feature.env_var, "").lower() in TRUE_VALUES
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
|
|
222
246
|
def convert_checks(ctx: click.core.Context, param: click.core.Parameter, value: Tuple[List[str]]) -> List[str]:
|
|
223
247
|
return sum(value, [])
|
|
224
248
|
|
schemathesis/cli/context.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import List, Optional, Union
|
|
|
6
6
|
|
|
7
7
|
import hypothesis
|
|
8
8
|
|
|
9
|
-
from ..
|
|
9
|
+
from ..code_samples import CodeSampleStyle
|
|
10
10
|
from ..runner.serialization import SerializedTestResult
|
|
11
11
|
|
|
12
12
|
|
|
@@ -31,6 +31,7 @@ class ExecutionContext:
|
|
|
31
31
|
workers_num: int = 1
|
|
32
32
|
rate_limit: Optional[str] = None
|
|
33
33
|
show_errors_tracebacks: bool = False
|
|
34
|
+
wait_for_schema: Optional[float] = None
|
|
34
35
|
validate_schema: bool = True
|
|
35
36
|
operations_processed: int = 0
|
|
36
37
|
# It is set in runtime, from a `Initialized` event
|
|
@@ -11,18 +11,22 @@ import requests
|
|
|
11
11
|
|
|
12
12
|
from ... import service
|
|
13
13
|
from ..._compat import metadata
|
|
14
|
-
from ...constants import
|
|
14
|
+
from ...constants import (
|
|
15
|
+
DISCORD_LINK,
|
|
16
|
+
FLAKY_FAILURE_MESSAGE,
|
|
17
|
+
REPORT_SUGGESTION_ENV_VAR,
|
|
18
|
+
SCHEMATHESIS_TEST_CASE_HEADER,
|
|
19
|
+
__version__,
|
|
20
|
+
)
|
|
21
|
+
from ...experimental import GLOBAL_EXPERIMENTS
|
|
15
22
|
from ...models import Response, Status
|
|
16
23
|
from ...runner import events
|
|
24
|
+
from ...runner.events import InternalErrorType, SchemaErrorType
|
|
17
25
|
from ...runner.serialization import SerializedCase, SerializedError, SerializedTestResult, deduplicate_failures
|
|
26
|
+
from ..callbacks import FALSE_VALUES
|
|
18
27
|
from ..context import ExecutionContext, FileReportContext, ServiceReportContext
|
|
19
28
|
from ..handlers import EventHandler
|
|
20
29
|
|
|
21
|
-
DISABLE_SCHEMA_VALIDATION_MESSAGE = (
|
|
22
|
-
"\nYou can disable input schema validation with --validate-schema=false "
|
|
23
|
-
"command-line option\nIn this case, Schemathesis cannot guarantee proper"
|
|
24
|
-
" behavior during the test run"
|
|
25
|
-
)
|
|
26
30
|
ISSUE_TRACKER_URL = (
|
|
27
31
|
"https://github.com/schemathesis/schemathesis/issues/new?"
|
|
28
32
|
"labels=Status%3A+Review+Needed%2C+Type%3A+Bug&template=bug_report.md&title=%5BBUG%5D"
|
|
@@ -140,8 +144,7 @@ def display_errors(context: ExecutionContext, event: events.Finished) -> None:
|
|
|
140
144
|
"Add this option to your command line parameters to see full tracebacks: --show-errors-tracebacks", fg="red"
|
|
141
145
|
)
|
|
142
146
|
click.secho(
|
|
143
|
-
f"\
|
|
144
|
-
f" {DISCORD_LINK}\n",
|
|
147
|
+
f"\nNeed more help?\n" f" Join our Discord server: {DISCORD_LINK}",
|
|
145
148
|
fg="red",
|
|
146
149
|
)
|
|
147
150
|
|
|
@@ -150,7 +153,7 @@ def display_single_error(context: ExecutionContext, result: SerializedTestResult
|
|
|
150
153
|
display_subsection(result)
|
|
151
154
|
should_display_full_traceback_message = False
|
|
152
155
|
for error in result.errors:
|
|
153
|
-
should_display_full_traceback_message |= _display_error(context, error
|
|
156
|
+
should_display_full_traceback_message |= _display_error(context, error)
|
|
154
157
|
return should_display_full_traceback_message
|
|
155
158
|
|
|
156
159
|
|
|
@@ -162,24 +165,20 @@ def display_generic_errors(context: ExecutionContext, errors: List[SerializedErr
|
|
|
162
165
|
|
|
163
166
|
def display_full_traceback_message(exception: str) -> bool:
|
|
164
167
|
# Some errors should not trigger the message that suggests to show full tracebacks to the user
|
|
165
|
-
return not exception.startswith("DeadlineExceeded")
|
|
168
|
+
return not exception.startswith(("DeadlineExceeded", "OperationSchemaError"))
|
|
166
169
|
|
|
167
170
|
|
|
168
|
-
def _display_error(context: ExecutionContext, error: SerializedError
|
|
171
|
+
def _display_error(context: ExecutionContext, error: SerializedError) -> bool:
|
|
169
172
|
if context.show_errors_tracebacks:
|
|
170
173
|
message = error.exception_with_traceback
|
|
171
174
|
else:
|
|
172
175
|
message = error.exception
|
|
173
|
-
if error.exception.startswith("InvalidSchema") and context.validate_schema:
|
|
174
|
-
message += DISABLE_SCHEMA_VALIDATION_MESSAGE + "\n"
|
|
175
176
|
if error.exception.startswith("DeadlineExceeded"):
|
|
176
177
|
message += (
|
|
177
178
|
"Consider extending the deadline with the `--hypothesis-deadline` CLI option.\n"
|
|
178
179
|
"You can disable it completely with `--hypothesis-deadline=None`.\n"
|
|
179
180
|
)
|
|
180
181
|
click.secho(message, fg="red")
|
|
181
|
-
if error.example is not None:
|
|
182
|
-
display_example(context, error.example, seed=seed)
|
|
183
182
|
return display_full_traceback_message(error.exception)
|
|
184
183
|
|
|
185
184
|
|
|
@@ -239,10 +238,20 @@ def display_example(
|
|
|
239
238
|
if response is not None and response.body is not None:
|
|
240
239
|
payload = base64.b64decode(response.body).decode(response.encoding or "utf8", errors="replace")
|
|
241
240
|
click.secho(f"Response status: {response.status_code}\nResponse payload: `{payload}`\n", fg="red")
|
|
242
|
-
if
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
241
|
+
request_body = base64.b64decode(case.body).decode() if case.body is not None else None
|
|
242
|
+
code_sample = context.code_sample_style.generate(
|
|
243
|
+
method=case.method,
|
|
244
|
+
url=case.url,
|
|
245
|
+
body=request_body,
|
|
246
|
+
headers=case.headers,
|
|
247
|
+
verify=case.verify,
|
|
248
|
+
extra_headers=case.extra_headers,
|
|
249
|
+
)
|
|
250
|
+
click.secho(
|
|
251
|
+
f"Run this {context.code_sample_style.verbose_name} to reproduce this failure: \n\n {code_sample}\n",
|
|
252
|
+
fg="red",
|
|
253
|
+
)
|
|
254
|
+
click.secho(f"{SCHEMATHESIS_TEST_CASE_HEADER}: {case.id}\n", fg="red")
|
|
246
255
|
if seed is not None:
|
|
247
256
|
click.secho(f"Or add this option to your command line parameters: --hypothesis-seed={seed}", fg="red")
|
|
248
257
|
|
|
@@ -295,6 +304,22 @@ def display_statistic(context: ExecutionContext, event: events.Finished) -> None
|
|
|
295
304
|
for warning in event.warnings:
|
|
296
305
|
click.secho(f" - {warning}", fg="yellow")
|
|
297
306
|
|
|
307
|
+
if len(GLOBAL_EXPERIMENTS.enabled) > 0:
|
|
308
|
+
click.secho("\nExperimental Features:", bold=True)
|
|
309
|
+
for experiment in sorted(GLOBAL_EXPERIMENTS.enabled, key=lambda e: e.name):
|
|
310
|
+
click.secho(f" - {experiment.verbose_name}: {experiment.description}")
|
|
311
|
+
click.secho(f" Feedback: {experiment.discussion_url}")
|
|
312
|
+
click.echo()
|
|
313
|
+
click.echo(
|
|
314
|
+
"Your feedback is crucial for experimental features. "
|
|
315
|
+
"Please visit the provided URL(s) to share your thoughts."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if event.failed_count > 0:
|
|
319
|
+
click.echo(
|
|
320
|
+
f"\n{bold('Note')}: The '{SCHEMATHESIS_TEST_CASE_HEADER}' header can be used to correlate test cases with server logs for debugging."
|
|
321
|
+
)
|
|
322
|
+
|
|
298
323
|
if context.report is not None and not context.is_interrupted:
|
|
299
324
|
if isinstance(context.report, FileReportContext):
|
|
300
325
|
click.echo()
|
|
@@ -304,6 +329,9 @@ def display_statistic(context: ExecutionContext, event: events.Finished) -> None
|
|
|
304
329
|
click.echo()
|
|
305
330
|
handle_service_integration(context.report)
|
|
306
331
|
else:
|
|
332
|
+
env_var = os.getenv(REPORT_SUGGESTION_ENV_VAR)
|
|
333
|
+
if env_var is not None and env_var.lower() in FALSE_VALUES:
|
|
334
|
+
return
|
|
307
335
|
click.echo()
|
|
308
336
|
category = click.style("Hint", bold=True)
|
|
309
337
|
click.echo(
|
|
@@ -354,7 +382,8 @@ def display_report_metadata(meta: service.Metadata) -> None:
|
|
|
354
382
|
def display_service_error(event: service.Error) -> None:
|
|
355
383
|
"""Show information about an error during communication with Schemathesis.io."""
|
|
356
384
|
if isinstance(event.exception, requests.HTTPError):
|
|
357
|
-
|
|
385
|
+
response = cast(requests.Response, event.exception.response)
|
|
386
|
+
status_code = response.status_code
|
|
358
387
|
click.secho(f"Schemathesis.io responded with HTTP {status_code}", fg="red")
|
|
359
388
|
if 500 <= status_code <= 599:
|
|
360
389
|
# Server error, should be resolved soon
|
|
@@ -447,23 +476,62 @@ def display_check_result(check_name: str, results: Dict[Union[str, Status], int]
|
|
|
447
476
|
click.echo(template.format(check_name, f"{success} / {total} passed", click.style(verdict, fg=color, bold=True)))
|
|
448
477
|
|
|
449
478
|
|
|
479
|
+
VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def bold(option: str) -> str:
|
|
483
|
+
return click.style(option, bold=True)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
SCHEMA_ERROR_SUGGESTIONS = {
|
|
487
|
+
# SSL-specific connection issue
|
|
488
|
+
SchemaErrorType.CONNECTION_SSL: f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}.",
|
|
489
|
+
# Other connection problems
|
|
490
|
+
SchemaErrorType.CONNECTION_OTHER: f"Use {bold('`--wait-for-schema=NUM`')} to wait up to NUM seconds for schema availability.",
|
|
491
|
+
# Response issues
|
|
492
|
+
SchemaErrorType.UNEXPECTED_CONTENT_TYPE: VERIFY_URL_SUGGESTION,
|
|
493
|
+
SchemaErrorType.HTTP_FORBIDDEN: "Verify your API keys or authentication headers.",
|
|
494
|
+
SchemaErrorType.HTTP_NOT_FOUND: VERIFY_URL_SUGGESTION,
|
|
495
|
+
# OpenAPI specification issues
|
|
496
|
+
SchemaErrorType.OPEN_API_UNSPECIFIED_VERSION: f"Include the version in the schema or manually set it with {bold('`--force-schema-version`')}.",
|
|
497
|
+
SchemaErrorType.OPEN_API_UNSUPPORTED_VERSION: f"Proceed with {bold('`--force-schema-version`')}. Caution: May not be fully supported.",
|
|
498
|
+
SchemaErrorType.OPEN_API_INVALID_SCHEMA: f"Bypass validation using {bold('`--validate-schema=false`')}. Caution: May cause unexpected errors.",
|
|
499
|
+
# YAML specific issues
|
|
500
|
+
SchemaErrorType.YAML_NUMERIC_STATUS_CODES: "Convert numeric status codes to strings.",
|
|
501
|
+
SchemaErrorType.YAML_NON_STRING_KEYS: "Convert non-string keys to strings.",
|
|
502
|
+
# Unclassified
|
|
503
|
+
SchemaErrorType.UNCLASSIFIED: f"If you suspect this is a Schemathesis issue and the schema is valid, please report it and include the schema if you can: {ISSUE_TRACKER_URL}",
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def should_skip_suggestion(context: ExecutionContext, event: events.InternalError) -> bool:
|
|
508
|
+
return event.subtype == SchemaErrorType.CONNECTION_OTHER and context.wait_for_schema is not None
|
|
509
|
+
|
|
510
|
+
|
|
450
511
|
def display_internal_error(context: ExecutionContext, event: events.InternalError) -> None:
|
|
451
|
-
click.secho(event.
|
|
512
|
+
click.secho(event.title, fg="red", bold=True)
|
|
452
513
|
click.echo()
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
514
|
+
click.secho(event.message)
|
|
515
|
+
if event.type == InternalErrorType.SCHEMA:
|
|
516
|
+
extras = event.extras
|
|
517
|
+
elif context.show_errors_tracebacks:
|
|
518
|
+
extras = [entry for entry in event.exception_with_traceback.splitlines() if entry]
|
|
519
|
+
else:
|
|
520
|
+
extras = [event.exception]
|
|
521
|
+
if extras:
|
|
522
|
+
click.echo()
|
|
523
|
+
for extra in extras:
|
|
524
|
+
click.secho(f" {extra}")
|
|
525
|
+
if not should_skip_suggestion(context, event):
|
|
526
|
+
if event.type == InternalErrorType.SCHEMA and isinstance(event.subtype, SchemaErrorType):
|
|
527
|
+
suggestion = SCHEMA_ERROR_SUGGESTIONS.get(event.subtype)
|
|
528
|
+
elif context.show_errors_tracebacks:
|
|
529
|
+
suggestion = f"Please consider reporting the traceback above to our issue tracker: {ISSUE_TRACKER_URL}."
|
|
459
530
|
else:
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
)
|
|
464
|
-
if event.exception_type == "schemathesis.exceptions.SchemaLoadingError":
|
|
465
|
-
message += "\n" + DISABLE_SCHEMA_VALIDATION_MESSAGE
|
|
466
|
-
click.secho(message, fg="red")
|
|
531
|
+
suggestion = f"To see full tracebacks, add {bold('`--show-errors-tracebacks`')} to your CLI options"
|
|
532
|
+
# Display suggestion if any
|
|
533
|
+
if suggestion is not None:
|
|
534
|
+
click.secho(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}")
|
|
467
535
|
|
|
468
536
|
|
|
469
537
|
def handle_initialized(context: ExecutionContext, event: events.Initialized) -> None:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from ..runner import events
|
|
4
|
+
from ..sanitization import sanitize_serialized_check, sanitize_serialized_interaction
|
|
5
|
+
from .handlers import EventHandler, ExecutionContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SanitizationHandler(EventHandler):
|
|
10
|
+
def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None:
|
|
11
|
+
if isinstance(event, events.AfterExecution):
|
|
12
|
+
for check in event.result.checks:
|
|
13
|
+
sanitize_serialized_check(check)
|
|
14
|
+
for interaction in event.result.interactions:
|
|
15
|
+
sanitize_serialized_interaction(interaction)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from shlex import quote
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
|
|
5
|
+
from requests.structures import CaseInsensitiveDict
|
|
6
|
+
from requests.utils import default_headers
|
|
7
|
+
|
|
8
|
+
from .constants import SCHEMATHESIS_TEST_CASE_HEADER, DataGenerationMethod
|
|
9
|
+
from .types import Headers
|
|
10
|
+
|
|
11
|
+
DEFAULT_DATA_GENERATION_METHODS = (DataGenerationMethod.default(),)
|
|
12
|
+
# These headers are added automatically by Schemathesis or `requests`.
|
|
13
|
+
# Do not show them in code samples to make them more readable
|
|
14
|
+
EXCLUDED_HEADERS = CaseInsensitiveDict(
|
|
15
|
+
{
|
|
16
|
+
"Content-Length": None,
|
|
17
|
+
"Transfer-Encoding": None,
|
|
18
|
+
SCHEMATHESIS_TEST_CASE_HEADER: None,
|
|
19
|
+
**default_headers(),
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CodeSampleStyle(str, Enum):
|
|
25
|
+
"""Controls the style of code samples for failure reproduction."""
|
|
26
|
+
|
|
27
|
+
python = "python"
|
|
28
|
+
curl = "curl"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def verbose_name(self) -> str:
|
|
32
|
+
return {
|
|
33
|
+
self.curl: "cURL command",
|
|
34
|
+
self.python: "Python code",
|
|
35
|
+
}[self]
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def default(cls) -> "CodeSampleStyle":
|
|
39
|
+
return cls.curl
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_str(cls, value: str) -> "CodeSampleStyle":
|
|
43
|
+
try:
|
|
44
|
+
return cls[value]
|
|
45
|
+
except KeyError:
|
|
46
|
+
available_styles = ", ".join(cls)
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Invalid value for code sample style: {value}. Available styles: {available_styles}"
|
|
49
|
+
) from None
|
|
50
|
+
|
|
51
|
+
def generate(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
method: str,
|
|
55
|
+
url: str,
|
|
56
|
+
body: Optional[Union[str, bytes]],
|
|
57
|
+
headers: Optional[Headers],
|
|
58
|
+
verify: bool,
|
|
59
|
+
extra_headers: Optional[Headers] = None,
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Generate a code snippet for making HTTP requests."""
|
|
62
|
+
handlers = {
|
|
63
|
+
self.curl: _generate_curl,
|
|
64
|
+
self.python: _generate_requests,
|
|
65
|
+
}
|
|
66
|
+
return handlers[self](
|
|
67
|
+
method=method, url=url, body=body, headers=_filter_headers(headers, extra_headers), verify=verify
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _filter_headers(headers: Optional[Headers], extra: Optional[Headers] = None) -> Headers:
|
|
72
|
+
headers = headers.copy() if headers else {}
|
|
73
|
+
if extra is not None:
|
|
74
|
+
for key, value in extra.items():
|
|
75
|
+
if key not in EXCLUDED_HEADERS:
|
|
76
|
+
headers[key] = value
|
|
77
|
+
return headers
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _generate_curl(
|
|
81
|
+
*,
|
|
82
|
+
method: str,
|
|
83
|
+
url: str,
|
|
84
|
+
body: Optional[Union[str, bytes]],
|
|
85
|
+
headers: Headers,
|
|
86
|
+
verify: bool,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Create a cURL command to reproduce an HTTP request."""
|
|
89
|
+
command = f"curl -X {method}"
|
|
90
|
+
for key, value in headers.items():
|
|
91
|
+
header = f"{key}: {value}"
|
|
92
|
+
command += f" -H {quote(header)}"
|
|
93
|
+
if body:
|
|
94
|
+
if isinstance(body, bytes):
|
|
95
|
+
body = body.decode("utf-8", errors="replace")
|
|
96
|
+
command += f" -d {quote(body)}"
|
|
97
|
+
if not verify:
|
|
98
|
+
command += " --insecure"
|
|
99
|
+
return f"{command} {quote(url)}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _generate_requests(
|
|
103
|
+
*,
|
|
104
|
+
method: str,
|
|
105
|
+
url: str,
|
|
106
|
+
body: Optional[Union[str, bytes]],
|
|
107
|
+
headers: Headers,
|
|
108
|
+
verify: bool,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Create a Python code to reproduce an HTTP request."""
|
|
111
|
+
url = _escape_single_quotes(url)
|
|
112
|
+
command = f"requests.{method.lower()}('{url}'"
|
|
113
|
+
if body:
|
|
114
|
+
command += f", data={repr(body)}"
|
|
115
|
+
if headers:
|
|
116
|
+
command += f", headers={repr(headers)}"
|
|
117
|
+
if not verify:
|
|
118
|
+
command += ", verify=False"
|
|
119
|
+
command += ")"
|
|
120
|
+
return command
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _escape_single_quotes(url: str) -> str:
|
|
124
|
+
"""Escape single quotes in a string, so it is usable as in generated Python code.
|
|
125
|
+
|
|
126
|
+
The usual ``str.replace`` is not suitable as it may convert already escaped quotes to not-escaped.
|
|
127
|
+
"""
|
|
128
|
+
result = []
|
|
129
|
+
escape = False
|
|
130
|
+
for char in url:
|
|
131
|
+
if escape:
|
|
132
|
+
result.append(char)
|
|
133
|
+
escape = False
|
|
134
|
+
elif char == "\\":
|
|
135
|
+
result.append(char)
|
|
136
|
+
escape = True
|
|
137
|
+
elif char == "'":
|
|
138
|
+
result.append("\\'")
|
|
139
|
+
else:
|
|
140
|
+
result.append(char)
|
|
141
|
+
return "".join(result)
|
schemathesis/constants.py
CHANGED
|
@@ -34,9 +34,6 @@ SERIALIZERS_SUGGESTION_MESSAGE = (
|
|
|
34
34
|
"and Schemathesis will be able to make API calls with this media type. \n"
|
|
35
35
|
"See https://schemathesis.readthedocs.io/en/stable/how.html#payload-serialization for more information."
|
|
36
36
|
)
|
|
37
|
-
USE_WAIT_FOR_SCHEMA_SUGGESTION_MESSAGE = (
|
|
38
|
-
"You can use `--wait-for-schema=NUM` to wait for a maximum of NUM seconds on the API schema availability."
|
|
39
|
-
)
|
|
40
37
|
FLAKY_FAILURE_MESSAGE = "[FLAKY] Schemathesis was not able to reliably reproduce this failure"
|
|
41
38
|
BOM_MARK = "\ufeff"
|
|
42
39
|
WAIT_FOR_SCHEMA_INTERVAL = 0.05
|
|
@@ -44,6 +41,7 @@ HOOKS_MODULE_ENV_VAR = "SCHEMATHESIS_HOOKS"
|
|
|
44
41
|
API_NAME_ENV_VAR = "SCHEMATHESIS_API_NAME"
|
|
45
42
|
BASE_URL_ENV_VAR = "SCHEMATHESIS_BASE_URL"
|
|
46
43
|
WAIT_FOR_SCHEMA_ENV_VAR = "SCHEMATHESIS_WAIT_FOR_SCHEMA"
|
|
44
|
+
REPORT_SUGGESTION_ENV_VAR = "SCHEMATHESIS_REPORT_SUGGESTION"
|
|
47
45
|
|
|
48
46
|
|
|
49
47
|
class DataGenerationMethod(str, Enum):
|
|
@@ -74,24 +72,3 @@ class DataGenerationMethod(str, Enum):
|
|
|
74
72
|
|
|
75
73
|
|
|
76
74
|
DEFAULT_DATA_GENERATION_METHODS = (DataGenerationMethod.default(),)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class CodeSampleStyle(str, Enum):
|
|
80
|
-
"""Controls the style of code samples for failure reproduction."""
|
|
81
|
-
|
|
82
|
-
python = "python"
|
|
83
|
-
curl = "curl"
|
|
84
|
-
|
|
85
|
-
@classmethod
|
|
86
|
-
def default(cls) -> "CodeSampleStyle":
|
|
87
|
-
return cls.curl
|
|
88
|
-
|
|
89
|
-
@classmethod
|
|
90
|
-
def from_str(cls, value: str) -> "CodeSampleStyle":
|
|
91
|
-
try:
|
|
92
|
-
return cls[value]
|
|
93
|
-
except KeyError:
|
|
94
|
-
available_styles = ", ".join(cls)
|
|
95
|
-
raise ValueError(
|
|
96
|
-
f"Invalid value for code sample style: {value}. Available styles: {available_styles}"
|
|
97
|
-
) from None
|