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/graphql/loaders.py
CHANGED
@@ -6,6 +6,7 @@ from os import PathLike
|
|
6
6
|
from pathlib import Path
|
7
7
|
from typing import IO, TYPE_CHECKING, Any, Callable, Dict, NoReturn, TypeVar, cast
|
8
8
|
|
9
|
+
from schemathesis.config import SchemathesisConfig
|
9
10
|
from schemathesis.core.errors import LoaderError, LoaderErrorKind
|
10
11
|
from schemathesis.core.loaders import load_from_url, prepare_request_kwargs, raise_for_status, require_relative_url
|
11
12
|
from schemathesis.hooks import HookContext, dispatch
|
@@ -17,16 +18,52 @@ if TYPE_CHECKING:
|
|
17
18
|
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
18
19
|
|
19
20
|
|
20
|
-
def from_asgi(path: str, app: Any, **kwargs: Any) -> GraphQLSchema:
|
21
|
+
def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
|
22
|
+
"""Load GraphQL schema from an ASGI application via introspection.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
path: Relative URL path to the GraphQL endpoint (e.g., "/graphql")
|
26
|
+
app: ASGI application instance
|
27
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
28
|
+
**kwargs: Additional request parameters passed to the ASGI test client.
|
29
|
+
|
30
|
+
Example:
|
31
|
+
```python
|
32
|
+
from fastapi import FastAPI
|
33
|
+
import schemathesis
|
34
|
+
|
35
|
+
app = FastAPI()
|
36
|
+
schema = schemathesis.graphql.from_asgi("/graphql", app)
|
37
|
+
```
|
38
|
+
|
39
|
+
"""
|
21
40
|
require_relative_url(path)
|
22
41
|
kwargs.setdefault("json", {"query": get_introspection_query()})
|
23
42
|
client = asgi.get_client(app)
|
24
43
|
response = load_from_url(client.post, url=path, **kwargs)
|
25
44
|
schema = extract_schema_from_response(response, lambda r: r.json())
|
26
|
-
return from_dict(schema=schema).configure(app=app, location=path)
|
45
|
+
return from_dict(schema=schema, config=config).configure(app=app, location=path)
|
46
|
+
|
47
|
+
|
48
|
+
def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
|
49
|
+
"""Load GraphQL schema from a WSGI application via introspection.
|
50
|
+
|
51
|
+
Args:
|
52
|
+
path: Relative URL path to the GraphQL endpoint (e.g., "/graphql")
|
53
|
+
app: WSGI application instance
|
54
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
55
|
+
**kwargs: Additional request parameters passed to the WSGI test client.
|
27
56
|
|
57
|
+
Example:
|
58
|
+
```python
|
59
|
+
from flask import Flask
|
60
|
+
import schemathesis
|
28
61
|
|
29
|
-
|
62
|
+
app = Flask(__name__)
|
63
|
+
schema = schemathesis.graphql.from_wsgi("/graphql", app)
|
64
|
+
```
|
65
|
+
|
66
|
+
"""
|
30
67
|
require_relative_url(path)
|
31
68
|
prepare_request_kwargs(kwargs)
|
32
69
|
kwargs.setdefault("json", {"query": get_introspection_query()})
|
@@ -34,27 +71,97 @@ def from_wsgi(path: str, app: Any, **kwargs: Any) -> GraphQLSchema:
|
|
34
71
|
response = client.post(path=path, **kwargs)
|
35
72
|
raise_for_status(response)
|
36
73
|
schema = extract_schema_from_response(response, lambda r: r.json)
|
37
|
-
return from_dict(schema=schema).configure(app=app, location=path)
|
74
|
+
return from_dict(schema=schema, config=config).configure(app=app, location=path)
|
75
|
+
|
76
|
+
|
77
|
+
def from_url(
|
78
|
+
url: str, *, config: SchemathesisConfig | None = None, wait_for_schema: float | None = None, **kwargs: Any
|
79
|
+
) -> GraphQLSchema:
|
80
|
+
"""Load GraphQL schema from a URL via introspection query.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
url: Full URL to the GraphQL endpoint
|
84
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
85
|
+
wait_for_schema: Maximum time in seconds to wait for schema availability
|
86
|
+
**kwargs: Additional parameters passed to `requests.post()` (headers, timeout, auth, etc.).
|
38
87
|
|
88
|
+
Example:
|
89
|
+
```python
|
90
|
+
import schemathesis
|
39
91
|
|
40
|
-
|
41
|
-
|
92
|
+
# Basic usage
|
93
|
+
schema = schemathesis.graphql.from_url("https://api.example.com/graphql")
|
94
|
+
|
95
|
+
# With authentication and timeout
|
96
|
+
schema = schemathesis.graphql.from_url(
|
97
|
+
"https://api.example.com/graphql",
|
98
|
+
headers={"Authorization": "Bearer token"},
|
99
|
+
timeout=30,
|
100
|
+
wait_for_schema=10.0
|
101
|
+
)
|
102
|
+
```
|
103
|
+
|
104
|
+
"""
|
42
105
|
import requests
|
43
106
|
|
44
107
|
kwargs.setdefault("json", {"query": get_introspection_query()})
|
45
108
|
response = load_from_url(requests.post, url=url, wait_for_schema=wait_for_schema, **kwargs)
|
46
109
|
schema = extract_schema_from_response(response, lambda r: r.json())
|
47
|
-
return from_dict(schema).configure(location=url)
|
110
|
+
return from_dict(schema, config=config).configure(location=url)
|
111
|
+
|
48
112
|
|
113
|
+
def from_path(
|
114
|
+
path: PathLike | str, *, config: SchemathesisConfig | None = None, encoding: str = "utf-8"
|
115
|
+
) -> GraphQLSchema:
|
116
|
+
"""Load GraphQL schema from a filesystem path.
|
49
117
|
|
50
|
-
|
51
|
-
|
118
|
+
Args:
|
119
|
+
path: File path to the GraphQL schema file (.graphql, .gql)
|
120
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
121
|
+
encoding: Text encoding for reading the file
|
122
|
+
|
123
|
+
Example:
|
124
|
+
```python
|
125
|
+
import schemathesis
|
126
|
+
|
127
|
+
# Load from GraphQL SDL file
|
128
|
+
schema = schemathesis.graphql.from_path("./schema.graphql")
|
129
|
+
```
|
130
|
+
|
131
|
+
"""
|
52
132
|
with open(path, encoding=encoding) as file:
|
53
|
-
return from_file(file=file).configure(location=Path(path).absolute().as_uri())
|
133
|
+
return from_file(file=file, config=config).configure(location=Path(path).absolute().as_uri())
|
134
|
+
|
135
|
+
|
136
|
+
def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
|
137
|
+
"""Load GraphQL schema from a file-like object or string.
|
138
|
+
|
139
|
+
Args:
|
140
|
+
file: File-like object or raw string containing GraphQL SDL
|
141
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
142
|
+
|
143
|
+
Example:
|
144
|
+
```python
|
145
|
+
import schemathesis
|
54
146
|
|
147
|
+
# From GraphQL SDL string
|
148
|
+
schema_sdl = '''
|
149
|
+
type Query {
|
150
|
+
user(id: ID!): User
|
151
|
+
}
|
152
|
+
type User {
|
153
|
+
id: ID!
|
154
|
+
name: String!
|
155
|
+
}
|
156
|
+
'''
|
157
|
+
schema = schemathesis.graphql.from_file(schema_sdl)
|
55
158
|
|
56
|
-
|
57
|
-
|
159
|
+
# From file object
|
160
|
+
with open("schema.graphql") as f:
|
161
|
+
schema = schemathesis.graphql.from_file(f)
|
162
|
+
```
|
163
|
+
|
164
|
+
"""
|
58
165
|
import graphql
|
59
166
|
|
60
167
|
if isinstance(file, str):
|
@@ -78,18 +185,54 @@ def from_file(file: IO[str] | str) -> GraphQLSchema:
|
|
78
185
|
_on_invalid_schema(exc)
|
79
186
|
except json.JSONDecodeError:
|
80
187
|
_on_invalid_schema(exc, extras=[entry for entry in str(exc).splitlines() if entry])
|
81
|
-
return from_dict(schema)
|
188
|
+
return from_dict(schema, config=config)
|
189
|
+
|
190
|
+
|
191
|
+
def from_dict(schema: dict[str, Any], *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
|
192
|
+
"""Load GraphQL schema from a dictionary containing introspection result.
|
193
|
+
|
194
|
+
Args:
|
195
|
+
schema: Dictionary containing GraphQL introspection result or wrapped in 'data' key
|
196
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
82
197
|
|
198
|
+
Example:
|
199
|
+
```python
|
200
|
+
import schemathesis
|
83
201
|
|
84
|
-
|
85
|
-
|
202
|
+
# From introspection result
|
203
|
+
introspection = {
|
204
|
+
"__schema": {
|
205
|
+
"types": [...],
|
206
|
+
"queryType": {"name": "Query"},
|
207
|
+
# ... rest of introspection result
|
208
|
+
}
|
209
|
+
}
|
210
|
+
schema = schemathesis.graphql.from_dict(introspection)
|
211
|
+
|
212
|
+
# From GraphQL response format (with 'data' wrapper)
|
213
|
+
response_data = {
|
214
|
+
"data": {
|
215
|
+
"__schema": {
|
216
|
+
"types": [...],
|
217
|
+
"queryType": {"name": "Query"}
|
218
|
+
}
|
219
|
+
}
|
220
|
+
}
|
221
|
+
schema = schemathesis.graphql.from_dict(response_data)
|
222
|
+
```
|
223
|
+
|
224
|
+
"""
|
86
225
|
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
87
226
|
|
88
227
|
if "data" in schema:
|
89
228
|
schema = schema["data"]
|
90
229
|
hook_context = HookContext()
|
91
230
|
dispatch("before_load_schema", hook_context, schema)
|
92
|
-
|
231
|
+
|
232
|
+
if config is None:
|
233
|
+
config = SchemathesisConfig.discover()
|
234
|
+
project_config = config.projects.get(schema)
|
235
|
+
instance = GraphQLSchema(schema, config=project_config)
|
93
236
|
dispatch("after_load_schema", hook_context, instance)
|
94
237
|
return instance
|
95
238
|
|
schemathesis/hooks.py
CHANGED
@@ -37,13 +37,15 @@ class RegisteredHook:
|
|
37
37
|
|
38
38
|
@dataclass
|
39
39
|
class HookContext:
|
40
|
-
"""A context that is passed to some hook functions.
|
40
|
+
"""A context that is passed to some hook functions."""
|
41
41
|
|
42
|
-
:
|
43
|
-
|
44
|
-
|
42
|
+
operation: APIOperation | None
|
43
|
+
"""API operation that is currently being processed."""
|
44
|
+
|
45
|
+
__slots__ = ("operation",)
|
45
46
|
|
46
|
-
operation: APIOperation | None = None
|
47
|
+
def __init__(self, *, operation: APIOperation | None = None) -> None:
|
48
|
+
self.operation = operation
|
47
49
|
|
48
50
|
|
49
51
|
def to_filterable_hook(dispatcher: HookDispatcher) -> Callable:
|
@@ -110,48 +112,29 @@ class HookDispatcher:
|
|
110
112
|
_specs: ClassVar[dict[str, RegisteredHook]] = {}
|
111
113
|
|
112
114
|
def __post_init__(self) -> None:
|
113
|
-
self.
|
114
|
-
|
115
|
-
def register(self, hook: str | Callable) -> Callable:
|
116
|
-
"""Register a new hook.
|
117
|
-
|
118
|
-
:param hook: Either a hook function or a string.
|
119
|
-
|
120
|
-
Can be used as a decorator in two forms.
|
121
|
-
Without arguments for registering hooks and autodetecting their names:
|
122
|
-
|
123
|
-
.. code-block:: python
|
124
|
-
|
125
|
-
@schemathesis.hook
|
126
|
-
def before_generate_query(context, strategy):
|
127
|
-
...
|
128
|
-
|
129
|
-
With a hook name as the first argument:
|
130
|
-
|
131
|
-
.. code-block:: python
|
115
|
+
self.hook = to_filterable_hook(self) # type: ignore[method-assign]
|
132
116
|
|
133
|
-
|
134
|
-
def hook(context, strategy):
|
135
|
-
...
|
136
|
-
"""
|
117
|
+
def hook(self, hook: str | Callable) -> Callable:
|
137
118
|
raise NotImplementedError
|
138
119
|
|
139
120
|
def apply(self, hook: Callable, *, name: str | None = None) -> Callable[[Callable], Callable]:
|
140
121
|
"""Register hook to run only on one test function.
|
141
122
|
|
142
|
-
:
|
143
|
-
|
144
|
-
|
145
|
-
.. code-block:: python
|
123
|
+
Args:
|
124
|
+
hook: A hook function.
|
125
|
+
name: A hook name.
|
146
126
|
|
147
|
-
|
127
|
+
Example:
|
128
|
+
```python
|
129
|
+
def filter_query(ctx, value):
|
148
130
|
...
|
149
131
|
|
150
132
|
|
151
|
-
@schema.hooks.apply(
|
133
|
+
@schema.hooks.apply(filter_query)
|
152
134
|
@schema.parametrize()
|
153
135
|
def test_api(case):
|
154
136
|
...
|
137
|
+
```
|
155
138
|
|
156
139
|
"""
|
157
140
|
if name is None:
|
@@ -391,6 +374,50 @@ def after_call(context: HookContext, case: Case, response: Response) -> None:
|
|
391
374
|
GLOBAL_HOOK_DISPATCHER = HookDispatcher(scope=HookScope.GLOBAL)
|
392
375
|
dispatch = GLOBAL_HOOK_DISPATCHER.dispatch
|
393
376
|
get_all_by_name = GLOBAL_HOOK_DISPATCHER.get_all_by_name
|
394
|
-
register = GLOBAL_HOOK_DISPATCHER.register
|
395
377
|
unregister = GLOBAL_HOOK_DISPATCHER.unregister
|
396
378
|
unregister_all = GLOBAL_HOOK_DISPATCHER.unregister_all
|
379
|
+
|
380
|
+
|
381
|
+
def hook(hook: str | Callable) -> Callable:
|
382
|
+
"""Register a new hook.
|
383
|
+
|
384
|
+
Args:
|
385
|
+
hook: Either a hook function (autodetecting its name) or a string matching one of the supported hook names.
|
386
|
+
|
387
|
+
Example:
|
388
|
+
Can be used as a decorator in two ways:
|
389
|
+
|
390
|
+
1. Without arguments (auto-detect the hook name from the function name):
|
391
|
+
|
392
|
+
```python
|
393
|
+
@schemathesis.hook
|
394
|
+
def filter_query(ctx, query):
|
395
|
+
\"\"\"Skip cases where query is None or invalid\"\"\"
|
396
|
+
return query and "user_id" in query
|
397
|
+
|
398
|
+
@schemathesis.hook
|
399
|
+
def before_call(ctx, case):
|
400
|
+
\"\"\"Modify headers before sending each request\"\"\"
|
401
|
+
if case.headers is None:
|
402
|
+
case.headers = {}
|
403
|
+
case.headers["X-Test-Mode"] = "true"
|
404
|
+
return None
|
405
|
+
```
|
406
|
+
|
407
|
+
2. With an explicit hook name as the first argument:
|
408
|
+
|
409
|
+
```python
|
410
|
+
@schemathesis.hook("map_headers")
|
411
|
+
def add_custom_header(ctx, headers):
|
412
|
+
\"\"\"Inject a test header into every request\"\"\"
|
413
|
+
if headers is None:
|
414
|
+
headers = {}
|
415
|
+
headers["X-Custom"] = "value"
|
416
|
+
return headers
|
417
|
+
```
|
418
|
+
|
419
|
+
"""
|
420
|
+
return GLOBAL_HOOK_DISPATCHER.hook(hook)
|
421
|
+
|
422
|
+
|
423
|
+
hook.__dict__ = GLOBAL_HOOK_DISPATCHER.hook.__dict__
|
schemathesis/openapi/checks.py
CHANGED
@@ -4,8 +4,9 @@ import textwrap
|
|
4
4
|
from dataclasses import dataclass, field
|
5
5
|
from typing import TYPE_CHECKING, Any
|
6
6
|
|
7
|
+
from schemathesis.config import OutputConfig
|
7
8
|
from schemathesis.core.failures import Failure, Severity
|
8
|
-
from schemathesis.core.output import
|
9
|
+
from schemathesis.core.output import truncate_json
|
9
10
|
|
10
11
|
if TYPE_CHECKING:
|
11
12
|
from jsonschema import ValidationError
|
@@ -141,11 +142,14 @@ class JsonSchemaError(Failure):
|
|
141
142
|
title: str = "Response violates schema",
|
142
143
|
operation: str,
|
143
144
|
exc: ValidationError,
|
144
|
-
|
145
|
+
config: OutputConfig | None = None,
|
145
146
|
) -> JsonSchemaError:
|
146
|
-
|
147
|
-
|
148
|
-
|
147
|
+
schema = textwrap.indent(
|
148
|
+
truncate_json(exc.schema, config=config or OutputConfig(), max_lines=20), prefix=" "
|
149
|
+
)
|
150
|
+
value = textwrap.indent(
|
151
|
+
truncate_json(exc.instance, config=config or OutputConfig(), max_lines=20), prefix=" "
|
152
|
+
)
|
149
153
|
schema_path = list(exc.absolute_schema_path)
|
150
154
|
if len(schema_path) > 1:
|
151
155
|
# Exclude the last segment, which is already in the schema
|
@@ -336,7 +340,7 @@ class IgnoredAuth(Failure):
|
|
336
340
|
class AcceptedNegativeData(Failure):
|
337
341
|
"""Response with negative data was accepted."""
|
338
342
|
|
339
|
-
__slots__ = ("operation", "message", "status_code", "
|
343
|
+
__slots__ = ("operation", "message", "status_code", "expected_statuses", "title", "case_id", "severity")
|
340
344
|
|
341
345
|
def __init__(
|
342
346
|
self,
|
@@ -344,14 +348,14 @@ class AcceptedNegativeData(Failure):
|
|
344
348
|
operation: str,
|
345
349
|
message: str,
|
346
350
|
status_code: int,
|
347
|
-
|
351
|
+
expected_statuses: list[str],
|
348
352
|
title: str = "Accepted negative data",
|
349
353
|
case_id: str | None = None,
|
350
354
|
) -> None:
|
351
355
|
self.operation = operation
|
352
356
|
self.message = message
|
353
357
|
self.status_code = status_code
|
354
|
-
self.
|
358
|
+
self.expected_statuses = expected_statuses
|
355
359
|
self.title = title
|
356
360
|
self.case_id = case_id
|
357
361
|
self.severity = Severity.MEDIUM
|
@@ -1,4 +1,5 @@
|
|
1
1
|
from collections.abc import Mapping
|
2
|
+
from typing import Any
|
2
3
|
|
3
4
|
from schemathesis.core import NOT_SET
|
4
5
|
from schemathesis.core.validation import contains_unicode_surrogate_pair, has_invalid_characters, is_latin_1_encodable
|
@@ -21,14 +22,15 @@ def is_valid_path(parameters: dict[str, object]) -> bool:
|
|
21
22
|
In this case one variable in the path template will be empty, which will lead to 404 in most of the cases.
|
22
23
|
Because of it this case doesn't bring much value and might lead to false positives results of Schemathesis runs.
|
23
24
|
"""
|
24
|
-
return not any(
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
)
|
31
|
-
|
25
|
+
return not any(is_invalid_path_parameter(value) for value in parameters.values())
|
26
|
+
|
27
|
+
|
28
|
+
def is_invalid_path_parameter(value: Any) -> bool:
|
29
|
+
return (
|
30
|
+
value in ("/", "")
|
31
|
+
or contains_unicode_surrogate_pair(value)
|
32
|
+
or isinstance(value, str)
|
33
|
+
and ("/" in value or "}" in value or "{" in value)
|
32
34
|
)
|
33
35
|
|
34
36
|
|
schemathesis/openapi/loaders.py
CHANGED
@@ -7,6 +7,7 @@ from os import PathLike
|
|
7
7
|
from pathlib import Path
|
8
8
|
from typing import IO, TYPE_CHECKING, Any, Mapping
|
9
9
|
|
10
|
+
from schemathesis.config import SchemathesisConfig
|
10
11
|
from schemathesis.core import media_types
|
11
12
|
from schemathesis.core.deserialization import deserialize_yaml
|
12
13
|
from schemathesis.core.errors import LoaderError, LoaderErrorKind
|
@@ -18,16 +19,52 @@ if TYPE_CHECKING:
|
|
18
19
|
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
19
20
|
|
20
21
|
|
21
|
-
def from_asgi(path: str, app: Any, **kwargs: Any) -> BaseOpenAPISchema:
|
22
|
+
def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> BaseOpenAPISchema:
|
23
|
+
"""Load OpenAPI schema from an ASGI application.
|
24
|
+
|
25
|
+
Args:
|
26
|
+
path: Relative URL path to the OpenAPI schema endpoint (e.g., "/openapi.json")
|
27
|
+
app: ASGI application instance
|
28
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
29
|
+
**kwargs: Additional request parameters passed to the ASGI test client
|
30
|
+
|
31
|
+
Example:
|
32
|
+
```python
|
33
|
+
from fastapi import FastAPI
|
34
|
+
import schemathesis
|
35
|
+
|
36
|
+
app = FastAPI()
|
37
|
+
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
|
38
|
+
```
|
39
|
+
|
40
|
+
"""
|
22
41
|
require_relative_url(path)
|
23
42
|
client = asgi.get_client(app)
|
24
43
|
response = load_from_url(client.get, url=path, **kwargs)
|
25
44
|
content_type = detect_content_type(headers=response.headers, path=path)
|
26
45
|
schema = load_content(response.text, content_type)
|
27
|
-
return from_dict(schema=schema).configure(app=app, location=path)
|
46
|
+
return from_dict(schema=schema, config=config).configure(app=app, location=path)
|
47
|
+
|
48
|
+
|
49
|
+
def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> BaseOpenAPISchema:
|
50
|
+
"""Load OpenAPI schema from a WSGI application.
|
51
|
+
|
52
|
+
Args:
|
53
|
+
path: Relative URL path to the OpenAPI schema endpoint (e.g., "/openapi.json")
|
54
|
+
app: WSGI application instance
|
55
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
56
|
+
**kwargs: Additional request parameters passed to the WSGI test client
|
57
|
+
|
58
|
+
Example:
|
59
|
+
```python
|
60
|
+
from flask import Flask
|
61
|
+
import schemathesis
|
28
62
|
|
63
|
+
app = Flask(__name__)
|
64
|
+
schema = schemathesis.openapi.from_wsgi("/openapi.json", app)
|
65
|
+
```
|
29
66
|
|
30
|
-
|
67
|
+
"""
|
31
68
|
require_relative_url(path)
|
32
69
|
prepare_request_kwargs(kwargs)
|
33
70
|
client = wsgi.get_client(app)
|
@@ -35,29 +72,94 @@ def from_wsgi(path: str, app: Any, **kwargs: Any) -> BaseOpenAPISchema:
|
|
35
72
|
raise_for_status(response)
|
36
73
|
content_type = detect_content_type(headers=response.headers, path=path)
|
37
74
|
schema = load_content(response.text, content_type)
|
38
|
-
return from_dict(schema=schema).configure(app=app, location=path)
|
75
|
+
return from_dict(schema=schema, config=config).configure(app=app, location=path)
|
39
76
|
|
40
77
|
|
41
|
-
def from_url(
|
42
|
-
|
78
|
+
def from_url(
|
79
|
+
url: str, *, config: SchemathesisConfig | None = None, wait_for_schema: float | None = None, **kwargs: Any
|
80
|
+
) -> BaseOpenAPISchema:
|
81
|
+
"""Load OpenAPI schema from a URL.
|
82
|
+
|
83
|
+
Args:
|
84
|
+
url: Full URL to the OpenAPI schema
|
85
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
86
|
+
wait_for_schema: Maximum time in seconds to wait for schema availability
|
87
|
+
**kwargs: Additional parameters passed to `requests.get()` (headers, timeout, auth, etc.)
|
88
|
+
|
89
|
+
Example:
|
90
|
+
```python
|
91
|
+
import schemathesis
|
92
|
+
|
93
|
+
# Basic usage
|
94
|
+
schema = schemathesis.openapi.from_url("https://api.example.com/openapi.json")
|
95
|
+
|
96
|
+
# With authentication and timeout
|
97
|
+
schema = schemathesis.openapi.from_url(
|
98
|
+
"https://api.example.com/openapi.json",
|
99
|
+
headers={"Authorization": "Bearer token"},
|
100
|
+
timeout=30,
|
101
|
+
wait_for_schema=10.0
|
102
|
+
)
|
103
|
+
```
|
104
|
+
|
105
|
+
"""
|
43
106
|
import requests
|
44
107
|
|
45
108
|
response = load_from_url(requests.get, url=url, wait_for_schema=wait_for_schema, **kwargs)
|
46
109
|
content_type = detect_content_type(headers=response.headers, path=url)
|
47
110
|
schema = load_content(response.text, content_type)
|
48
|
-
return from_dict(schema=schema).configure(location=url)
|
111
|
+
return from_dict(schema=schema, config=config).configure(location=url)
|
112
|
+
|
49
113
|
|
114
|
+
def from_path(
|
115
|
+
path: PathLike | str, *, config: SchemathesisConfig | None = None, encoding: str = "utf-8"
|
116
|
+
) -> BaseOpenAPISchema:
|
117
|
+
"""Load OpenAPI schema from a filesystem path.
|
50
118
|
|
51
|
-
|
52
|
-
|
119
|
+
Args:
|
120
|
+
path: File path to the OpenAPI schema (supports JSON / YAML)
|
121
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
122
|
+
encoding: Text encoding for reading the file
|
123
|
+
|
124
|
+
Example:
|
125
|
+
```python
|
126
|
+
import schemathesis
|
127
|
+
|
128
|
+
# Load from file
|
129
|
+
schema = schemathesis.openapi.from_path("./specs/openapi.yaml")
|
130
|
+
|
131
|
+
# With custom encoding
|
132
|
+
schema = schemathesis.openapi.from_path("./specs/openapi.json", encoding="cp1252")
|
133
|
+
```
|
134
|
+
|
135
|
+
"""
|
53
136
|
with open(path, encoding=encoding) as file:
|
54
137
|
content_type = detect_content_type(headers=None, path=str(path))
|
55
138
|
schema = load_content(file.read(), content_type)
|
56
|
-
return from_dict(schema=schema).configure(location=Path(path).absolute().as_uri())
|
139
|
+
return from_dict(schema=schema, config=config).configure(location=Path(path).absolute().as_uri())
|
140
|
+
|
141
|
+
|
142
|
+
def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> BaseOpenAPISchema:
|
143
|
+
"""Load OpenAPI schema from a file-like object or string.
|
144
|
+
|
145
|
+
Args:
|
146
|
+
file: File-like object or raw string containing the OpenAPI schema
|
147
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
148
|
+
|
149
|
+
Example:
|
150
|
+
```python
|
151
|
+
import schemathesis
|
57
152
|
|
153
|
+
# From string
|
154
|
+
schema_content = '{"openapi": "3.0.0", "info": {"title": "API"}}'
|
155
|
+
schema = schemathesis.openapi.from_file(schema_content)
|
58
156
|
|
59
|
-
|
60
|
-
|
157
|
+
# From file object
|
158
|
+
with open("openapi.yaml") as f:
|
159
|
+
schema = schemathesis.openapi.from_file(f)
|
160
|
+
```
|
161
|
+
|
162
|
+
"""
|
61
163
|
if isinstance(file, str):
|
62
164
|
data = file
|
63
165
|
else:
|
@@ -66,11 +168,30 @@ def from_file(file: IO[str] | str) -> BaseOpenAPISchema:
|
|
66
168
|
schema = json.loads(data)
|
67
169
|
except json.JSONDecodeError:
|
68
170
|
schema = _load_yaml(data)
|
69
|
-
return from_dict(schema)
|
171
|
+
return from_dict(schema, config=config)
|
172
|
+
|
173
|
+
|
174
|
+
def from_dict(schema: dict[str, Any], *, config: SchemathesisConfig | None = None) -> BaseOpenAPISchema:
|
175
|
+
"""Load OpenAPI schema from a dictionary.
|
176
|
+
|
177
|
+
Args:
|
178
|
+
schema: Dictionary containing the parsed OpenAPI schema
|
179
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
70
180
|
|
181
|
+
Example:
|
182
|
+
```python
|
183
|
+
import schemathesis
|
71
184
|
|
72
|
-
|
73
|
-
|
185
|
+
schema_dict = {
|
186
|
+
"openapi": "3.0.0",
|
187
|
+
"info": {"title": "My API", "version": "1.0.0"},
|
188
|
+
"paths": {"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
|
189
|
+
}
|
190
|
+
|
191
|
+
schema = schemathesis.openapi.from_dict(schema_dict)
|
192
|
+
```
|
193
|
+
|
194
|
+
"""
|
74
195
|
from schemathesis.specs.openapi.schemas import OpenApi30, SwaggerV20
|
75
196
|
|
76
197
|
if not isinstance(schema, dict):
|
@@ -78,8 +199,12 @@ def from_dict(schema: dict[str, Any]) -> BaseOpenAPISchema:
|
|
78
199
|
hook_context = HookContext()
|
79
200
|
dispatch("before_load_schema", hook_context, schema)
|
80
201
|
|
202
|
+
if config is None:
|
203
|
+
config = SchemathesisConfig.discover()
|
204
|
+
project_config = config.projects.get(schema)
|
205
|
+
|
81
206
|
if "swagger" in schema:
|
82
|
-
instance = SwaggerV20(schema)
|
207
|
+
instance = SwaggerV20(raw_schema=schema, config=project_config)
|
83
208
|
elif "openapi" in schema:
|
84
209
|
version = schema["openapi"]
|
85
210
|
if not OPENAPI_VERSION_RE.match(version):
|
@@ -87,7 +212,7 @@ def from_dict(schema: dict[str, Any]) -> BaseOpenAPISchema:
|
|
87
212
|
LoaderErrorKind.OPEN_API_UNSUPPORTED_VERSION,
|
88
213
|
f"The provided schema uses Open API {version}, which is currently not supported.",
|
89
214
|
)
|
90
|
-
instance = OpenApi30(schema)
|
215
|
+
instance = OpenApi30(raw_schema=schema, config=project_config)
|
91
216
|
else:
|
92
217
|
raise LoaderError(
|
93
218
|
LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION,
|