schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a12__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 +29 -30
- schemathesis/auths.py +65 -24
- schemathesis/checks.py +73 -39
- schemathesis/cli/commands/__init__.py +51 -3
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +163 -274
- schemathesis/cli/commands/run/context.py +8 -4
- schemathesis/cli/commands/run/events.py +11 -1
- schemathesis/cli/commands/run/executor.py +70 -78
- schemathesis/cli/commands/run/filters.py +15 -165
- schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
- schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
- schemathesis/cli/commands/run/handlers/output.py +195 -121
- schemathesis/cli/commands/run/loaders.py +35 -50
- schemathesis/cli/commands/run/validation.py +52 -162
- schemathesis/cli/core.py +5 -3
- schemathesis/cli/ext/fs.py +7 -5
- schemathesis/cli/ext/options.py +0 -21
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +523 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +2 -0
- schemathesis/core/compat.py +16 -9
- schemathesis/core/errors.py +24 -4
- schemathesis/core/failures.py +6 -7
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/output/__init__.py +14 -37
- schemathesis/core/output/sanitization.py +3 -146
- schemathesis/core/transport.py +36 -1
- schemathesis/core/validation.py +16 -0
- schemathesis/engine/__init__.py +2 -4
- schemathesis/engine/context.py +42 -43
- schemathesis/engine/core.py +7 -5
- schemathesis/engine/errors.py +60 -1
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/__init__.py +10 -0
- schemathesis/engine/phases/probes.py +11 -8
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +104 -46
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/__init__.py +23 -15
- schemathesis/engine/phases/unit/_executor.py +110 -21
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/errors.py +2 -0
- schemathesis/filters.py +2 -3
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +6 -3
- schemathesis/generation/coverage.py +154 -124
- schemathesis/generation/hypothesis/builder.py +70 -20
- schemathesis/generation/meta.py +3 -3
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +37 -1
- schemathesis/generation/stateful/__init__.py +4 -0
- schemathesis/generation/stateful/state_machine.py +9 -1
- schemathesis/graphql/loaders.py +159 -16
- schemathesis/hooks.py +62 -35
- schemathesis/openapi/checks.py +12 -8
- schemathesis/openapi/generation/filters.py +10 -8
- schemathesis/openapi/loaders.py +142 -17
- schemathesis/pytest/lazy.py +2 -5
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +33 -2
- schemathesis/schemas.py +21 -66
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +23 -18
- schemathesis/specs/openapi/_hypothesis.py +26 -28
- schemathesis/specs/openapi/checks.py +37 -36
- schemathesis/specs/openapi/examples.py +4 -3
- schemathesis/specs/openapi/formats.py +32 -5
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/negative/__init__.py +2 -2
- schemathesis/specs/openapi/patterns.py +46 -16
- schemathesis/specs/openapi/references.py +2 -3
- schemathesis/specs/openapi/schemas.py +19 -22
- schemathesis/specs/openapi/stateful/__init__.py +12 -6
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +38 -13
- schemathesis/transport/requests.py +12 -9
- schemathesis/transport/wsgi.py +11 -12
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +50 -97
- schemathesis-4.0.0a12.dist-info/RECORD +164 -0
- schemathesis/cli/commands/run/checks.py +0 -79
- schemathesis/cli/commands/run/hypothesis.py +0 -78
- schemathesis/cli/commands/run/reports.py +0 -72
- schemathesis/cli/hooks.py +0 -36
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/engine/config.py +0 -59
- schemathesis/experimental/__init__.py +0 -72
- schemathesis/generation/targets.py +0 -69
- schemathesis-4.0.0a10.dist-info/RECORD +0 -153
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/licenses/LICENSE +0 -0
schemathesis/__init__.py
CHANGED
@@ -1,48 +1,47 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from schemathesis import
|
3
|
+
from schemathesis import errors, graphql, openapi, pytest
|
4
|
+
from schemathesis.auths import AuthContext, auth
|
4
5
|
from schemathesis.checks import CheckContext, CheckFunction, check
|
5
|
-
from schemathesis.core.output import OutputConfig, sanitization
|
6
6
|
from schemathesis.core.transport import Response
|
7
7
|
from schemathesis.core.version import SCHEMATHESIS_VERSION
|
8
|
-
from schemathesis.generation import
|
8
|
+
from schemathesis.generation import GenerationMode
|
9
9
|
from schemathesis.generation.case import Case
|
10
|
-
from schemathesis.generation.
|
11
|
-
from schemathesis.hooks import HookContext
|
10
|
+
from schemathesis.generation.metrics import MetricContext, MetricFunction, metric
|
11
|
+
from schemathesis.hooks import HookContext, hook
|
12
12
|
from schemathesis.schemas import BaseSchema
|
13
|
+
from schemathesis.transport import SerializationContext, serializer
|
13
14
|
|
14
15
|
__version__ = SCHEMATHESIS_VERSION
|
15
16
|
|
16
|
-
# Public API
|
17
|
-
auth = auths.GLOBAL_AUTH_STORAGE
|
18
|
-
hook = hooks.register
|
19
|
-
|
20
17
|
__all__ = [
|
18
|
+
"__version__",
|
19
|
+
# Core data structures
|
21
20
|
"Case",
|
22
|
-
"CheckContext",
|
23
|
-
"CheckFunction",
|
24
|
-
"GenerationMode",
|
25
|
-
"GenerationConfig",
|
26
|
-
"HeaderConfig",
|
27
|
-
"OutputConfig",
|
28
21
|
"Response",
|
29
|
-
"
|
30
|
-
"TargetFunction",
|
31
|
-
"HookContext",
|
22
|
+
"GenerationMode",
|
32
23
|
"BaseSchema",
|
33
|
-
|
34
|
-
"auth",
|
35
|
-
"check",
|
36
|
-
"contrib",
|
37
|
-
"engine",
|
24
|
+
# Public errors
|
38
25
|
"errors",
|
39
|
-
|
40
|
-
"graphql",
|
41
|
-
"hook",
|
42
|
-
"hooks",
|
26
|
+
# Spec or usage specific namespaces
|
43
27
|
"openapi",
|
28
|
+
"graphql",
|
44
29
|
"pytest",
|
45
|
-
|
46
|
-
"
|
47
|
-
"
|
30
|
+
# Hooks
|
31
|
+
"hook",
|
32
|
+
"HookContext",
|
33
|
+
# Checks
|
34
|
+
"check",
|
35
|
+
"CheckContext",
|
36
|
+
"CheckFunction",
|
37
|
+
# Auth
|
38
|
+
"auth",
|
39
|
+
"AuthContext",
|
40
|
+
# Targeted Property-based Testing
|
41
|
+
"metric",
|
42
|
+
"MetricContext",
|
43
|
+
"MetricFunction",
|
44
|
+
# Serialization
|
45
|
+
"serializer",
|
46
|
+
"SerializationContext",
|
48
47
|
]
|
schemathesis/auths.py
CHANGED
@@ -34,14 +34,34 @@ Auth = TypeVar("Auth")
|
|
34
34
|
|
35
35
|
@dataclass
|
36
36
|
class AuthContext:
|
37
|
-
"""
|
37
|
+
"""Runtime context passed to authentication providers during token generation.
|
38
|
+
|
39
|
+
Provides access to the current API operation and application instance when
|
40
|
+
auth providers need operation-specific tokens or application state.
|
41
|
+
|
42
|
+
Example:
|
43
|
+
```python
|
44
|
+
@schemathesis.auth()
|
45
|
+
class ContextAwareAuth:
|
46
|
+
def get(self, case, context):
|
47
|
+
# Access operation details
|
48
|
+
if "/admin/" in context.operation.path:
|
49
|
+
return self.get_admin_token()
|
50
|
+
else:
|
51
|
+
return self.get_user_token()
|
52
|
+
|
53
|
+
def set(self, case, data, context):
|
54
|
+
case.headers = {"Authorization": f"Bearer {data}"}
|
55
|
+
```
|
38
56
|
|
39
|
-
:ivar APIOperation operation: API operation that is currently being processed.
|
40
|
-
:ivar app: Optional Python application if the WSGI / ASGI integration is used.
|
41
57
|
"""
|
42
58
|
|
43
59
|
operation: APIOperation
|
60
|
+
"""API operation currently being processed for authentication."""
|
44
61
|
app: Any | None
|
62
|
+
"""Python application instance (ASGI/WSGI app) when using app integration, `None` otherwise."""
|
63
|
+
|
64
|
+
__slots__ = ("operation", "app")
|
45
65
|
|
46
66
|
|
47
67
|
CacheKeyFunction = Callable[["Case", "AuthContext"], Union[str, int]]
|
@@ -289,7 +309,7 @@ class AuthStorage(Generic[Auth]):
|
|
289
309
|
) -> FilterableRegisterAuth | FilterableApplyAuth:
|
290
310
|
if provider_class is not None:
|
291
311
|
return self.apply(provider_class, refresh_interval=refresh_interval, cache_by_key=cache_by_key)
|
292
|
-
return self.
|
312
|
+
return self.auth(refresh_interval=refresh_interval, cache_by_key=cache_by_key)
|
293
313
|
|
294
314
|
def set_from_requests(self, auth: requests.auth.AuthBase) -> FilterableRequestsAuth:
|
295
315
|
"""Use `requests` auth instance as an auth provider."""
|
@@ -333,30 +353,12 @@ class AuthStorage(Generic[Auth]):
|
|
333
353
|
provider = SelectiveAuthProvider(provider, filter_set)
|
334
354
|
self.providers.append(provider)
|
335
355
|
|
336
|
-
def
|
356
|
+
def auth(
|
337
357
|
self,
|
338
358
|
*,
|
339
359
|
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
340
360
|
cache_by_key: CacheKeyFunction | None = None,
|
341
361
|
) -> FilterableRegisterAuth:
|
342
|
-
"""Register a new auth provider.
|
343
|
-
|
344
|
-
.. code-block:: python
|
345
|
-
|
346
|
-
@schemathesis.auth()
|
347
|
-
class TokenAuth:
|
348
|
-
def get(self, context):
|
349
|
-
response = requests.post(
|
350
|
-
"https://example.schemathesis.io/api/token/",
|
351
|
-
json={"username": "demo", "password": "test"},
|
352
|
-
)
|
353
|
-
data = response.json()
|
354
|
-
return data["access_token"]
|
355
|
-
|
356
|
-
def set(self, case, data, context):
|
357
|
-
# Modify `case` the way you need
|
358
|
-
case.headers = {"Authorization": f"Bearer {data}"}
|
359
|
-
"""
|
360
362
|
filter_set = FilterSet()
|
361
363
|
|
362
364
|
def wrapper(provider_class: type[AuthProvider]) -> type[AuthProvider]:
|
@@ -451,5 +453,44 @@ def set_on_case(case: Case, context: AuthContext, auth_storage: AuthStorage | No
|
|
451
453
|
|
452
454
|
# Global auth API
|
453
455
|
GLOBAL_AUTH_STORAGE: AuthStorage = AuthStorage()
|
454
|
-
register = GLOBAL_AUTH_STORAGE.register
|
455
456
|
unregister = GLOBAL_AUTH_STORAGE.unregister
|
457
|
+
|
458
|
+
|
459
|
+
def auth(
|
460
|
+
*,
|
461
|
+
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
462
|
+
cache_by_key: CacheKeyFunction | None = None,
|
463
|
+
) -> FilterableRegisterAuth:
|
464
|
+
"""Register a dynamic authentication provider for APIs with expiring tokens.
|
465
|
+
|
466
|
+
Args:
|
467
|
+
refresh_interval: Seconds between token refreshes. Default is `300`. Use `None` to disable caching
|
468
|
+
cache_by_key: Function to generate cache keys for different auth contexts (e.g., OAuth scopes)
|
469
|
+
|
470
|
+
Example:
|
471
|
+
```python
|
472
|
+
import schemathesis
|
473
|
+
import requests
|
474
|
+
|
475
|
+
@schemathesis.auth()
|
476
|
+
class TokenAuth:
|
477
|
+
def get(self, case, context):
|
478
|
+
\"\"\"Fetch fresh authentication token\"\"\"
|
479
|
+
response = requests.post(
|
480
|
+
"http://localhost:8000/auth/token",
|
481
|
+
json={"username": "demo", "password": "test"}
|
482
|
+
)
|
483
|
+
return response.json()["access_token"]
|
484
|
+
|
485
|
+
def set(self, case, data, context):
|
486
|
+
\"\"\"Apply token to test case headers\"\"\"
|
487
|
+
case.headers = case.headers or {}
|
488
|
+
case.headers["Authorization"] = f"Bearer {data}"
|
489
|
+
```
|
490
|
+
|
491
|
+
"""
|
492
|
+
return GLOBAL_AUTH_STORAGE.auth(refresh_interval=refresh_interval, cache_by_key=cache_by_key)
|
493
|
+
|
494
|
+
|
495
|
+
auth.__dict__ = GLOBAL_AUTH_STORAGE.auth.__dict__
|
496
|
+
auth.set_from_requests = GLOBAL_AUTH_STORAGE.set_from_requests # type: ignore[attr-defined]
|
schemathesis/checks.py
CHANGED
@@ -3,12 +3,12 @@ from __future__ import annotations
|
|
3
3
|
import json
|
4
4
|
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Optional
|
5
5
|
|
6
|
+
from schemathesis.config import ChecksConfig
|
6
7
|
from schemathesis.core.failures import (
|
7
8
|
CustomFailure,
|
8
9
|
Failure,
|
9
10
|
FailureGroup,
|
10
11
|
MalformedJson,
|
11
|
-
MaxResponseTimeConfig,
|
12
12
|
ResponseTimeExceeded,
|
13
13
|
ServerError,
|
14
14
|
)
|
@@ -23,23 +23,24 @@ if TYPE_CHECKING:
|
|
23
23
|
from schemathesis.generation.case import Case
|
24
24
|
|
25
25
|
CheckFunction = Callable[["CheckContext", "Response", "Case"], Optional[bool]]
|
26
|
-
ChecksConfig = dict[CheckFunction, Any]
|
27
26
|
|
28
27
|
|
29
28
|
class CheckContext:
|
30
|
-
"""
|
29
|
+
"""Runtime context passed to validation check functions during API testing.
|
31
30
|
|
32
|
-
Provides access to
|
31
|
+
Provides access to configuration for currently checked endpoint.
|
33
32
|
"""
|
34
33
|
|
35
|
-
|
36
|
-
|
37
|
-
|
34
|
+
_override: Override | None
|
35
|
+
_auth: tuple[str, str] | None
|
36
|
+
_headers: CaseInsensitiveDict | None
|
38
37
|
config: ChecksConfig
|
39
|
-
|
40
|
-
|
38
|
+
"""Configuration settings for validation checks."""
|
39
|
+
_transport_kwargs: dict[str, Any] | None
|
40
|
+
_recorder: ScenarioRecorder | None
|
41
|
+
_checks: list[CheckFunction]
|
41
42
|
|
42
|
-
__slots__ = ("
|
43
|
+
__slots__ = ("_override", "_auth", "_headers", "config", "_transport_kwargs", "_recorder", "_checks")
|
43
44
|
|
44
45
|
def __init__(
|
45
46
|
self,
|
@@ -50,48 +51,78 @@ class CheckContext:
|
|
50
51
|
transport_kwargs: dict[str, Any] | None,
|
51
52
|
recorder: ScenarioRecorder | None = None,
|
52
53
|
) -> None:
|
53
|
-
self.
|
54
|
-
self.
|
55
|
-
self.
|
54
|
+
self._override = override
|
55
|
+
self._auth = auth
|
56
|
+
self._headers = headers
|
56
57
|
self.config = config
|
57
|
-
self.
|
58
|
-
self.
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
58
|
+
self._transport_kwargs = transport_kwargs
|
59
|
+
self._recorder = recorder
|
60
|
+
self._checks = []
|
61
|
+
for check in CHECKS.get_all():
|
62
|
+
name = check.__name__
|
63
|
+
if self.config.get_by_name(name=name).enabled:
|
64
|
+
self._checks.append(check)
|
65
|
+
if self.config.max_response_time.enabled:
|
66
|
+
self._checks.append(max_response_time)
|
67
|
+
|
68
|
+
def _find_parent(self, *, case_id: str) -> Case | None:
|
69
|
+
if self._recorder is not None:
|
70
|
+
return self._recorder.find_parent(case_id=case_id)
|
63
71
|
return None
|
64
72
|
|
65
|
-
def
|
66
|
-
if self.
|
67
|
-
yield from self.
|
73
|
+
def _find_related(self, *, case_id: str) -> Iterator[Case]:
|
74
|
+
if self._recorder is not None:
|
75
|
+
yield from self._recorder.find_related(case_id=case_id)
|
68
76
|
|
69
|
-
def
|
70
|
-
if self.
|
71
|
-
return self.
|
77
|
+
def _find_response(self, *, case_id: str) -> Response | None:
|
78
|
+
if self._recorder is not None:
|
79
|
+
return self._recorder.find_response(case_id=case_id)
|
72
80
|
return None
|
73
81
|
|
74
|
-
def
|
75
|
-
if self.
|
76
|
-
self.
|
82
|
+
def _record_case(self, *, parent_id: str, case: Case) -> None:
|
83
|
+
if self._recorder is not None:
|
84
|
+
self._recorder.record_case(parent_id=parent_id, transition=None, case=case)
|
77
85
|
|
78
|
-
def
|
79
|
-
if self.
|
80
|
-
self.
|
86
|
+
def _record_response(self, *, case_id: str, response: Response) -> None:
|
87
|
+
if self._recorder is not None:
|
88
|
+
self._recorder.record_response(case_id=case_id, response=response)
|
81
89
|
|
82
90
|
|
83
91
|
CHECKS = Registry[CheckFunction]()
|
84
|
-
|
92
|
+
|
93
|
+
|
94
|
+
def check(func: CheckFunction) -> CheckFunction:
|
95
|
+
"""Register a custom validation check to run against API responses.
|
96
|
+
|
97
|
+
Args:
|
98
|
+
func: Function that takes `(ctx: CheckContext, response: Response, case: Case)` and raises `AssertionError` on validation failure
|
99
|
+
|
100
|
+
Example:
|
101
|
+
```python
|
102
|
+
import schemathesis
|
103
|
+
|
104
|
+
@schemathesis.check
|
105
|
+
def check_cors_headers(ctx, response, case):
|
106
|
+
\"\"\"Verify CORS headers are present\"\"\"
|
107
|
+
if "Access-Control-Allow-Origin" not in response.headers:
|
108
|
+
raise AssertionError("Missing CORS headers")
|
109
|
+
```
|
110
|
+
|
111
|
+
"""
|
112
|
+
return CHECKS.register(func)
|
85
113
|
|
86
114
|
|
87
115
|
@check
|
88
116
|
def not_a_server_error(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
89
117
|
"""A check to verify that the response is not a server-side error."""
|
90
|
-
from .specs.graphql.schemas import GraphQLSchema
|
91
|
-
from .specs.graphql.validation import validate_graphql_response
|
118
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
119
|
+
from schemathesis.specs.graphql.validation import validate_graphql_response
|
120
|
+
from schemathesis.specs.openapi.utils import expand_status_codes
|
121
|
+
|
122
|
+
expected_statuses = expand_status_codes(ctx.config.not_a_server_error.expected_statuses or [])
|
92
123
|
|
93
124
|
status_code = response.status_code
|
94
|
-
if status_code
|
125
|
+
if status_code not in expected_statuses:
|
95
126
|
raise ServerError(operation=case.operation.label, status_code=status_code)
|
96
127
|
if isinstance(case.operation.schema, GraphQLSchema):
|
97
128
|
try:
|
@@ -102,15 +133,18 @@ def not_a_server_error(ctx: CheckContext, response: Response, case: Case) -> boo
|
|
102
133
|
return None
|
103
134
|
|
104
135
|
|
136
|
+
DEFAULT_MAX_RESPONSE_TIME = 10.0
|
137
|
+
|
138
|
+
|
105
139
|
def max_response_time(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
106
|
-
|
140
|
+
limit = ctx.config.max_response_time.limit or DEFAULT_MAX_RESPONSE_TIME
|
107
141
|
elapsed = response.elapsed
|
108
|
-
if elapsed >
|
142
|
+
if elapsed > limit:
|
109
143
|
raise ResponseTimeExceeded(
|
110
144
|
operation=case.operation.label,
|
111
|
-
message=f"Actual: {elapsed:.2f}ms\nLimit: {
|
145
|
+
message=f"Actual: {elapsed:.2f}ms\nLimit: {limit * 1000:.2f}ms",
|
112
146
|
elapsed=elapsed,
|
113
|
-
deadline=
|
147
|
+
deadline=limit,
|
114
148
|
)
|
115
149
|
return None
|
116
150
|
|
@@ -4,20 +4,68 @@ from dataclasses import dataclass
|
|
4
4
|
from typing import Any
|
5
5
|
|
6
6
|
import click
|
7
|
+
from tomli import TOMLDecodeError
|
7
8
|
|
8
|
-
from schemathesis.cli import
|
9
|
+
from schemathesis.cli.commands.data import Data
|
9
10
|
from schemathesis.cli.commands.run import run as run_command
|
11
|
+
from schemathesis.cli.commands.run.handlers.output import display_header
|
12
|
+
from schemathesis.cli.constants import EXTENSIONS_DOCUMENTATION_URL
|
10
13
|
from schemathesis.cli.core import get_terminal_width
|
11
14
|
from schemathesis.cli.ext.groups import CommandWithGroupedOptions, GroupedOption
|
15
|
+
from schemathesis.config import ConfigError, SchemathesisConfig
|
16
|
+
from schemathesis.core.errors import HookError, format_exception
|
17
|
+
from schemathesis.core.version import SCHEMATHESIS_VERSION
|
12
18
|
|
13
19
|
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
14
20
|
|
15
21
|
|
16
22
|
@click.group(context_settings=CONTEXT_SETTINGS) # type: ignore[misc]
|
23
|
+
@click.option( # type: ignore[misc]
|
24
|
+
"--config-file",
|
25
|
+
"config_file",
|
26
|
+
help="The path to `schemathesis.toml` file to use for configuration",
|
27
|
+
metavar="PATH",
|
28
|
+
type=str,
|
29
|
+
)
|
30
|
+
@click.pass_context # type: ignore[misc]
|
17
31
|
@click.version_option() # type: ignore[misc]
|
18
|
-
def schemathesis() -> None:
|
32
|
+
def schemathesis(ctx: click.Context, config_file: str | None) -> None:
|
19
33
|
"""Property-based API testing for OpenAPI and GraphQL."""
|
20
|
-
|
34
|
+
try:
|
35
|
+
if config_file is not None:
|
36
|
+
config = SchemathesisConfig.from_path(config_file)
|
37
|
+
else:
|
38
|
+
config = SchemathesisConfig.discover()
|
39
|
+
except (TOMLDecodeError, ConfigError) as exc:
|
40
|
+
display_header(SCHEMATHESIS_VERSION)
|
41
|
+
click.secho(
|
42
|
+
f"❌ Failed to load configuration file{f' from {config_file}' if config_file else ''}",
|
43
|
+
fg="red",
|
44
|
+
bold=True,
|
45
|
+
)
|
46
|
+
if isinstance(exc, TOMLDecodeError):
|
47
|
+
detail = "The configuration file content is not valid TOML"
|
48
|
+
else:
|
49
|
+
detail = "The loaded configuration is incorrect"
|
50
|
+
click.echo(f"\n{detail}\n\n{exc}")
|
51
|
+
ctx.exit(1)
|
52
|
+
except HookError as exc:
|
53
|
+
click.secho("Unable to load Schemathesis extension hooks", fg="red", bold=True)
|
54
|
+
formatted_module_name = click.style(f"'{exc.module_path}'", bold=True)
|
55
|
+
cause = exc.__cause__
|
56
|
+
assert isinstance(cause, Exception)
|
57
|
+
if isinstance(cause, ModuleNotFoundError) and cause.name == exc.module_path:
|
58
|
+
click.echo(
|
59
|
+
f"\nAn attempt to import the module {formatted_module_name} failed because it could not be found."
|
60
|
+
)
|
61
|
+
click.echo("\nEnsure the module name is correctly spelled and reachable from the current directory.")
|
62
|
+
else:
|
63
|
+
click.echo(f"\nAn error occurred while importing the module {formatted_module_name}. Traceback:")
|
64
|
+
message = format_exception(cause, with_traceback=True, skip_frames=1)
|
65
|
+
click.secho(f"\n{message}", fg="red")
|
66
|
+
click.echo(f"\nFor more information on how to work with hooks, visit {EXTENSIONS_DOCUMENTATION_URL}")
|
67
|
+
ctx.exit(1)
|
68
|
+
ctx.obj = Data(config=config)
|
21
69
|
|
22
70
|
|
23
71
|
@dataclass
|