schemathesis 4.0.0a12__py3-none-any.whl → 4.0.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/__init__.py +9 -4
- schemathesis/auths.py +20 -30
- schemathesis/checks.py +5 -0
- schemathesis/cli/commands/run/__init__.py +9 -6
- schemathesis/cli/commands/run/handlers/output.py +13 -0
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/_operations.py +16 -21
- schemathesis/config/_projects.py +5 -1
- schemathesis/core/errors.py +10 -17
- schemathesis/core/transport.py +81 -1
- schemathesis/engine/errors.py +1 -1
- schemathesis/generation/case.py +152 -28
- schemathesis/generation/hypothesis/builder.py +12 -12
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +13 -0
- schemathesis/generation/stateful/state_machine.py +31 -108
- schemathesis/graphql/loaders.py +14 -4
- schemathesis/hooks.py +1 -4
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +14 -4
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/plugin.py +21 -11
- schemathesis/schemas.py +153 -89
- schemathesis/specs/graphql/schemas.py +6 -6
- schemathesis/specs/openapi/_hypothesis.py +39 -14
- schemathesis/specs/openapi/checks.py +95 -34
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +2 -2
- schemathesis/specs/openapi/parameters.py +0 -3
- schemathesis/specs/openapi/schemas.py +6 -91
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/requests.py +12 -1
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +7 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/METADATA +8 -10
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/RECORD +41 -41
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/graphql/loaders.py
CHANGED
@@ -42,7 +42,10 @@ def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
|
|
42
42
|
client = asgi.get_client(app)
|
43
43
|
response = load_from_url(client.post, url=path, **kwargs)
|
44
44
|
schema = extract_schema_from_response(response, lambda r: r.json())
|
45
|
-
|
45
|
+
loaded = from_dict(schema=schema, config=config)
|
46
|
+
loaded.app = app
|
47
|
+
loaded.location = path
|
48
|
+
return loaded
|
46
49
|
|
47
50
|
|
48
51
|
def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
|
@@ -71,7 +74,10 @@ def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
|
|
71
74
|
response = client.post(path=path, **kwargs)
|
72
75
|
raise_for_status(response)
|
73
76
|
schema = extract_schema_from_response(response, lambda r: r.json)
|
74
|
-
|
77
|
+
loaded = from_dict(schema=schema, config=config)
|
78
|
+
loaded.app = app
|
79
|
+
loaded.location = path
|
80
|
+
return loaded
|
75
81
|
|
76
82
|
|
77
83
|
def from_url(
|
@@ -107,7 +113,9 @@ def from_url(
|
|
107
113
|
kwargs.setdefault("json", {"query": get_introspection_query()})
|
108
114
|
response = load_from_url(requests.post, url=url, wait_for_schema=wait_for_schema, **kwargs)
|
109
115
|
schema = extract_schema_from_response(response, lambda r: r.json())
|
110
|
-
|
116
|
+
loaded = from_dict(schema, config=config)
|
117
|
+
loaded.location = url
|
118
|
+
return loaded
|
111
119
|
|
112
120
|
|
113
121
|
def from_path(
|
@@ -130,7 +138,9 @@ def from_path(
|
|
130
138
|
|
131
139
|
"""
|
132
140
|
with open(path, encoding=encoding) as file:
|
133
|
-
|
141
|
+
loaded = from_file(file=file, config=config)
|
142
|
+
loaded.location = Path(path).absolute().as_uri()
|
143
|
+
return loaded
|
134
144
|
|
135
145
|
|
136
146
|
def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
|
schemathesis/hooks.py
CHANGED
@@ -231,10 +231,7 @@ class HookDispatcher:
|
|
231
231
|
hook(context, *args, **kwargs)
|
232
232
|
|
233
233
|
def unregister(self, hook: Callable) -> None:
|
234
|
-
"""Unregister a specific hook.
|
235
|
-
|
236
|
-
:param hook: A hook function to unregister.
|
237
|
-
"""
|
234
|
+
"""Unregister a specific hook."""
|
238
235
|
# It removes this function from all places
|
239
236
|
for hooks in self._hooks.values():
|
240
237
|
hooks[:] = [item for item in hooks if item is not hook]
|
schemathesis/openapi/checks.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import textwrap
|
4
|
-
from dataclasses import dataclass, field
|
5
4
|
from typing import TYPE_CHECKING, Any
|
6
5
|
|
7
6
|
from schemathesis.config import OutputConfig
|
@@ -12,24 +11,6 @@ if TYPE_CHECKING:
|
|
12
11
|
from jsonschema import ValidationError
|
13
12
|
|
14
13
|
|
15
|
-
@dataclass
|
16
|
-
class NegativeDataRejectionConfig:
|
17
|
-
# 5xx will pass through
|
18
|
-
allowed_statuses: list[str] = field(
|
19
|
-
default_factory=lambda: ["400", "401", "403", "404", "406", "422", "428", "5xx"]
|
20
|
-
)
|
21
|
-
|
22
|
-
|
23
|
-
@dataclass
|
24
|
-
class PositiveDataAcceptanceConfig:
|
25
|
-
allowed_statuses: list[str] = field(default_factory=lambda: ["2xx", "401", "403", "404"])
|
26
|
-
|
27
|
-
|
28
|
-
@dataclass
|
29
|
-
class MissingRequiredHeaderConfig:
|
30
|
-
allowed_statuses: list[str] = field(default_factory=lambda: ["406"])
|
31
|
-
|
32
|
-
|
33
14
|
class UndefinedStatusCode(Failure):
|
34
15
|
"""Response has a status code that is not defined in the schema."""
|
35
16
|
|
@@ -323,7 +304,7 @@ class IgnoredAuth(Failure):
|
|
323
304
|
*,
|
324
305
|
operation: str,
|
325
306
|
message: str,
|
326
|
-
title: str = "
|
307
|
+
title: str = "API accepts requests without authentication",
|
327
308
|
case_id: str | None = None,
|
328
309
|
) -> None:
|
329
310
|
self.operation = operation
|
@@ -391,3 +372,84 @@ class RejectedPositiveData(Failure):
|
|
391
372
|
@property
|
392
373
|
def _unique_key(self) -> str:
|
393
374
|
return str(self.status_code)
|
375
|
+
|
376
|
+
|
377
|
+
class MissingHeaderNotRejected(Failure):
|
378
|
+
"""API did not reject request without required header."""
|
379
|
+
|
380
|
+
__slots__ = (
|
381
|
+
"operation",
|
382
|
+
"header_name",
|
383
|
+
"status_code",
|
384
|
+
"expected_statuses",
|
385
|
+
"message",
|
386
|
+
"title",
|
387
|
+
"case_id",
|
388
|
+
"severity",
|
389
|
+
)
|
390
|
+
|
391
|
+
def __init__(
|
392
|
+
self,
|
393
|
+
*,
|
394
|
+
operation: str,
|
395
|
+
header_name: str,
|
396
|
+
status_code: int,
|
397
|
+
expected_statuses: list[int],
|
398
|
+
message: str,
|
399
|
+
title: str = "Missing header not rejected",
|
400
|
+
case_id: str | None = None,
|
401
|
+
) -> None:
|
402
|
+
self.operation = operation
|
403
|
+
self.header_name = header_name
|
404
|
+
self.status_code = status_code
|
405
|
+
self.expected_statuses = expected_statuses
|
406
|
+
self.message = message
|
407
|
+
self.title = title
|
408
|
+
self.case_id = case_id
|
409
|
+
self.severity = Severity.MEDIUM
|
410
|
+
|
411
|
+
@property
|
412
|
+
def _unique_key(self) -> str:
|
413
|
+
return self.header_name
|
414
|
+
|
415
|
+
|
416
|
+
class UnsupportedMethodResponse(Failure):
|
417
|
+
"""API response for unsupported HTTP method is incorrect."""
|
418
|
+
|
419
|
+
__slots__ = (
|
420
|
+
"operation",
|
421
|
+
"method",
|
422
|
+
"status_code",
|
423
|
+
"allow_header_present",
|
424
|
+
"failure_reason",
|
425
|
+
"message",
|
426
|
+
"title",
|
427
|
+
"case_id",
|
428
|
+
"severity",
|
429
|
+
)
|
430
|
+
|
431
|
+
def __init__(
|
432
|
+
self,
|
433
|
+
*,
|
434
|
+
operation: str,
|
435
|
+
method: str,
|
436
|
+
status_code: int,
|
437
|
+
allow_header_present: bool | None = None,
|
438
|
+
failure_reason: str, # "wrong_status" or "missing_allow_header"
|
439
|
+
message: str,
|
440
|
+
title: str = "Unsupported method incorrect response",
|
441
|
+
case_id: str | None = None,
|
442
|
+
) -> None:
|
443
|
+
self.operation = operation
|
444
|
+
self.method = method
|
445
|
+
self.status_code = status_code
|
446
|
+
self.allow_header_present = allow_header_present
|
447
|
+
self.failure_reason = failure_reason
|
448
|
+
self.message = message
|
449
|
+
self.title = title
|
450
|
+
self.case_id = case_id
|
451
|
+
self.severity = Severity.MEDIUM
|
452
|
+
|
453
|
+
@property
|
454
|
+
def _unique_key(self) -> str:
|
455
|
+
return self.failure_reason
|
@@ -29,8 +29,15 @@ def is_invalid_path_parameter(value: Any) -> bool:
|
|
29
29
|
return (
|
30
30
|
value in ("/", "")
|
31
31
|
or contains_unicode_surrogate_pair(value)
|
32
|
-
or
|
33
|
-
|
32
|
+
or (
|
33
|
+
isinstance(value, str)
|
34
|
+
and (
|
35
|
+
("/" in value or "}" in value or "{" in value)
|
36
|
+
# Avoid situations when the path parameter contains only NULL bytes
|
37
|
+
# Many webservers remove such bytes and as the result, the test can target a different API operation
|
38
|
+
or (len(value) == value.count("\x00"))
|
39
|
+
)
|
40
|
+
)
|
34
41
|
)
|
35
42
|
|
36
43
|
|
schemathesis/openapi/loaders.py
CHANGED
@@ -43,7 +43,10 @@ def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
|
|
43
43
|
response = load_from_url(client.get, url=path, **kwargs)
|
44
44
|
content_type = detect_content_type(headers=response.headers, path=path)
|
45
45
|
schema = load_content(response.text, content_type)
|
46
|
-
|
46
|
+
loaded = from_dict(schema=schema, config=config)
|
47
|
+
loaded.app = app
|
48
|
+
loaded.location = path
|
49
|
+
return loaded
|
47
50
|
|
48
51
|
|
49
52
|
def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> BaseOpenAPISchema:
|
@@ -72,7 +75,10 @@ def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
|
|
72
75
|
raise_for_status(response)
|
73
76
|
content_type = detect_content_type(headers=response.headers, path=path)
|
74
77
|
schema = load_content(response.text, content_type)
|
75
|
-
|
78
|
+
loaded = from_dict(schema=schema, config=config)
|
79
|
+
loaded.app = app
|
80
|
+
loaded.location = path
|
81
|
+
return loaded
|
76
82
|
|
77
83
|
|
78
84
|
def from_url(
|
@@ -108,7 +114,9 @@ def from_url(
|
|
108
114
|
response = load_from_url(requests.get, url=url, wait_for_schema=wait_for_schema, **kwargs)
|
109
115
|
content_type = detect_content_type(headers=response.headers, path=url)
|
110
116
|
schema = load_content(response.text, content_type)
|
111
|
-
|
117
|
+
loaded = from_dict(schema=schema, config=config)
|
118
|
+
loaded.location = url
|
119
|
+
return loaded
|
112
120
|
|
113
121
|
|
114
122
|
def from_path(
|
@@ -136,7 +144,9 @@ def from_path(
|
|
136
144
|
with open(path, encoding=encoding) as file:
|
137
145
|
content_type = detect_content_type(headers=None, path=str(path))
|
138
146
|
schema = load_content(file.read(), content_type)
|
139
|
-
|
147
|
+
loaded = from_dict(schema=schema, config=config)
|
148
|
+
loaded.location = Path(path).absolute().as_uri()
|
149
|
+
return loaded
|
140
150
|
|
141
151
|
|
142
152
|
def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> BaseOpenAPISchema:
|
schemathesis/pytest/lazy.py
CHANGED
@@ -12,6 +12,7 @@ from pytest_subtests import SubTests
|
|
12
12
|
from schemathesis.core.errors import InvalidSchema
|
13
13
|
from schemathesis.core.result import Ok, Result
|
14
14
|
from schemathesis.filters import FilterSet, FilterValue, MatcherFunc, RegexValue, is_deprecated
|
15
|
+
from schemathesis.generation import overrides
|
15
16
|
from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, HypothesisTestMode, create_test
|
16
17
|
from schemathesis.generation.hypothesis.given import (
|
17
18
|
GivenArgsMark,
|
@@ -22,7 +23,6 @@ from schemathesis.generation.hypothesis.given import (
|
|
22
23
|
merge_given_args,
|
23
24
|
validate_given_args,
|
24
25
|
)
|
25
|
-
from schemathesis.generation.overrides import Override, OverrideMark, check_no_override_mark
|
26
26
|
from schemathesis.pytest.control_flow import fail_on_no_matches
|
27
27
|
from schemathesis.schemas import BaseSchema
|
28
28
|
|
@@ -174,17 +174,10 @@ class LazySchema:
|
|
174
174
|
node_id = request.node._nodeid
|
175
175
|
settings = getattr(wrapped_test, "_hypothesis_internal_use_settings", None)
|
176
176
|
|
177
|
-
as_strategy_kwargs:
|
177
|
+
def as_strategy_kwargs(_operation: APIOperation) -> dict[str, Any]:
|
178
|
+
override = overrides.for_operation(config=schema.config, operation=_operation)
|
178
179
|
|
179
|
-
|
180
|
-
if override is not None:
|
181
|
-
|
182
|
-
def as_strategy_kwargs(_operation: APIOperation) -> dict[str, Any]:
|
183
|
-
nonlocal override
|
184
|
-
|
185
|
-
return {
|
186
|
-
location: entry for location, entry in override.for_operation(_operation).items() if entry
|
187
|
-
}
|
180
|
+
return {location: entry for location, entry in override.items() if entry}
|
188
181
|
|
189
182
|
tests = list(
|
190
183
|
get_all_tests(
|
@@ -224,26 +217,6 @@ class LazySchema:
|
|
224
217
|
def given(self, *args: GivenInput, **kwargs: GivenInput) -> Callable:
|
225
218
|
return given_proxy(*args, **kwargs)
|
226
219
|
|
227
|
-
def override(
|
228
|
-
self,
|
229
|
-
*,
|
230
|
-
query: dict[str, str] | None = None,
|
231
|
-
headers: dict[str, str] | None = None,
|
232
|
-
cookies: dict[str, str] | None = None,
|
233
|
-
path_parameters: dict[str, str] | None = None,
|
234
|
-
) -> Callable[[Callable], Callable]:
|
235
|
-
"""Override Open API parameters with fixed values."""
|
236
|
-
|
237
|
-
def _add_override(test: Callable) -> Callable:
|
238
|
-
check_no_override_mark(test)
|
239
|
-
override = Override(
|
240
|
-
query=query or {}, headers=headers or {}, cookies=cookies or {}, path_parameters=path_parameters or {}
|
241
|
-
)
|
242
|
-
OverrideMark.set(test, override)
|
243
|
-
return test
|
244
|
-
|
245
|
-
return _add_override
|
246
|
-
|
247
220
|
|
248
221
|
def _copy_marks(source: Callable, target: Callable) -> None:
|
249
222
|
marks = getattr(source, "pytestmark", [])
|
schemathesis/pytest/plugin.py
CHANGED
@@ -26,6 +26,7 @@ from schemathesis.core.errors import (
|
|
26
26
|
from schemathesis.core.failures import FailureGroup
|
27
27
|
from schemathesis.core.marks import Mark
|
28
28
|
from schemathesis.core.result import Ok, Result
|
29
|
+
from schemathesis.generation import overrides
|
29
30
|
from schemathesis.generation.hypothesis.given import (
|
30
31
|
GivenArgsMark,
|
31
32
|
GivenKwargsMark,
|
@@ -34,7 +35,6 @@ from schemathesis.generation.hypothesis.given import (
|
|
34
35
|
validate_given_args,
|
35
36
|
)
|
36
37
|
from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_output
|
37
|
-
from schemathesis.generation.overrides import OverrideMark
|
38
38
|
from schemathesis.pytest.control_flow import fail_on_no_matches
|
39
39
|
from schemathesis.schemas import APIOperation
|
40
40
|
|
@@ -110,6 +110,7 @@ class SchemathesisCase(PyCollector):
|
|
110
110
|
This implementation is based on the original one in pytest, but with slight adjustments
|
111
111
|
to produce tests out of hypothesis ones.
|
112
112
|
"""
|
113
|
+
from schemathesis.checks import load_all_checks
|
113
114
|
from schemathesis.generation.hypothesis.builder import (
|
114
115
|
HypothesisTestConfig,
|
115
116
|
HypothesisTestMode,
|
@@ -117,6 +118,8 @@ class SchemathesisCase(PyCollector):
|
|
117
118
|
make_async_test,
|
118
119
|
)
|
119
120
|
|
121
|
+
load_all_checks()
|
122
|
+
|
120
123
|
is_trio_test = False
|
121
124
|
for mark in getattr(self.test_function, "pytestmark", []):
|
122
125
|
if mark.name == "trio":
|
@@ -128,14 +131,22 @@ class SchemathesisCase(PyCollector):
|
|
128
131
|
if self.is_invalid_test:
|
129
132
|
funcobj = self.test_function
|
130
133
|
else:
|
131
|
-
|
134
|
+
as_strategy_kwargs = {}
|
135
|
+
|
136
|
+
auth = self.schema.config.auth_for(operation=operation)
|
137
|
+
if auth is not None:
|
138
|
+
from requests.auth import _basic_auth_str
|
139
|
+
|
140
|
+
as_strategy_kwargs["headers"] = {"Authorization": _basic_auth_str(*auth)}
|
141
|
+
headers = self.schema.config.headers_for(operation=operation)
|
142
|
+
if headers:
|
143
|
+
as_strategy_kwargs["headers"] = headers
|
144
|
+
|
145
|
+
override = overrides.for_operation(operation=operation, config=self.schema.config)
|
132
146
|
if override is not None:
|
133
|
-
|
134
|
-
for location, entry in override.for_operation(operation).items():
|
147
|
+
for location, entry in override.items():
|
135
148
|
if entry:
|
136
149
|
as_strategy_kwargs[location] = entry
|
137
|
-
else:
|
138
|
-
as_strategy_kwargs = {}
|
139
150
|
modes = []
|
140
151
|
phases = self.schema.config.phases_for(operation=operation)
|
141
152
|
if phases.examples.enabled:
|
@@ -150,6 +161,7 @@ class SchemathesisCase(PyCollector):
|
|
150
161
|
test_func=self.test_function,
|
151
162
|
config=HypothesisTestConfig(
|
152
163
|
modes=modes,
|
164
|
+
settings=self.schema.config.get_hypothesis_settings(operation=operation),
|
153
165
|
given_kwargs=self.given_kwargs,
|
154
166
|
project=self.schema.config,
|
155
167
|
as_strategy_kwargs=as_strategy_kwargs,
|
@@ -256,16 +268,14 @@ def pytest_exception_interact(node: Function, call: pytest.CallInfo, report: pyt
|
|
256
268
|
total_frames = len(tb_entries)
|
257
269
|
|
258
270
|
# Keep internal Schemathesis frames + one extra one from the caller
|
259
|
-
|
271
|
+
skip_frames = 0
|
260
272
|
for i in range(total_frames - 1, -1, -1):
|
261
273
|
entry = tb_entries[i]
|
262
274
|
|
263
|
-
if
|
264
|
-
|
275
|
+
if not str(entry.path).endswith("schemathesis/generation/case.py"):
|
276
|
+
skip_frames = i
|
265
277
|
break
|
266
278
|
|
267
|
-
skip_frames = keep_from_index
|
268
|
-
|
269
279
|
report.longrepr = "".join(format_exception(call.excinfo.value, with_traceback=True, skip_frames=skip_frames))
|
270
280
|
|
271
281
|
|