schemathesis 4.0.0a11__py3-none-any.whl → 4.0.0b1__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 +35 -27
- schemathesis/auths.py +85 -54
- schemathesis/checks.py +65 -36
- schemathesis/cli/commands/run/__init__.py +32 -27
- schemathesis/cli/commands/run/context.py +6 -1
- schemathesis/cli/commands/run/events.py +7 -1
- schemathesis/cli/commands/run/executor.py +12 -7
- schemathesis/cli/commands/run/handlers/output.py +188 -80
- schemathesis/cli/commands/run/validation.py +21 -6
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/__init__.py +2 -1
- schemathesis/config/_generation.py +12 -13
- schemathesis/config/_operations.py +14 -0
- schemathesis/config/_phases.py +41 -5
- schemathesis/config/_projects.py +33 -1
- schemathesis/config/_report.py +6 -2
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +49 -1
- schemathesis/core/errors.py +15 -19
- schemathesis/core/transport.py +117 -2
- schemathesis/engine/context.py +1 -0
- schemathesis/engine/errors.py +61 -2
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/probes.py +3 -0
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +38 -5
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/_executor.py +36 -7
- schemathesis/generation/__init__.py +0 -3
- schemathesis/generation/case.py +153 -28
- schemathesis/generation/coverage.py +1 -1
- schemathesis/generation/hypothesis/builder.py +43 -19
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +17 -0
- schemathesis/generation/stateful/state_machine.py +32 -108
- schemathesis/graphql/loaders.py +152 -8
- schemathesis/hooks.py +63 -39
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +134 -8
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +38 -6
- schemathesis/schemas.py +161 -94
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +18 -9
- schemathesis/specs/openapi/_hypothesis.py +53 -34
- schemathesis/specs/openapi/checks.py +111 -47
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/formats.py +30 -3
- schemathesis/specs/openapi/media_types.py +44 -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 +14 -93
- schemathesis/specs/openapi/stateful/__init__.py +2 -1
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +31 -7
- schemathesis/transport/requests.py +21 -9
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +15 -8
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/generation/targets.py +0 -69
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -7,6 +7,21 @@ if TYPE_CHECKING:
|
|
7
7
|
|
8
8
|
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
9
9
|
|
10
|
+
__all__ = [
|
11
|
+
"APIStateMachine",
|
12
|
+
]
|
13
|
+
|
14
|
+
|
15
|
+
def __getattr__(name: str) -> type[APIStateMachine]:
|
16
|
+
if name == "APIStateMachine":
|
17
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
18
|
+
|
19
|
+
return APIStateMachine
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
21
|
+
|
22
|
+
|
23
|
+
STATEFUL_TESTS_LABEL = "Stateful tests"
|
24
|
+
|
10
25
|
|
11
26
|
def run_state_machine_as_test(
|
12
27
|
state_machine_factory: type[APIStateMachine], *, settings: hypothesis.settings | None = None
|
@@ -17,4 +32,6 @@ def run_state_machine_as_test(
|
|
17
32
|
"""
|
18
33
|
from hypothesis.stateful import run_state_machine_as_test as _run_state_machine_as_test
|
19
34
|
|
35
|
+
__tracebackhide__ = True
|
36
|
+
|
20
37
|
return _run_state_machine_as_test(state_machine_factory, settings=settings, _min_steps=2)
|
@@ -129,9 +129,10 @@ def _normalize_name(name: str) -> str:
|
|
129
129
|
|
130
130
|
|
131
131
|
class APIStateMachine(RuleBasedStateMachine):
|
132
|
-
"""
|
132
|
+
"""State machine for executing API operation sequences based on OpenAPI links.
|
133
133
|
|
134
|
-
|
134
|
+
Automatically generates test scenarios by chaining API operations according
|
135
|
+
to their defined relationships in the schema.
|
135
136
|
"""
|
136
137
|
|
137
138
|
# This is a convenience attribute, which happened to clash with `RuleBasedStateMachine` instance level attribute
|
@@ -193,16 +194,22 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
193
194
|
|
194
195
|
@classmethod
|
195
196
|
def run(cls, *, settings: hypothesis.settings | None = None) -> None:
|
196
|
-
"""
|
197
|
+
"""Execute the state machine test scenarios.
|
198
|
+
|
199
|
+
Args:
|
200
|
+
settings: Hypothesis settings for test execution.
|
201
|
+
|
202
|
+
"""
|
197
203
|
from . import run_state_machine_as_test
|
198
204
|
|
205
|
+
__tracebackhide__ = True
|
199
206
|
return run_state_machine_as_test(cls, settings=settings)
|
200
207
|
|
201
208
|
def setup(self) -> None:
|
202
|
-
"""
|
209
|
+
"""Called once at the beginning of each test scenario."""
|
203
210
|
|
204
211
|
def teardown(self) -> None:
|
205
|
-
|
212
|
+
"""Called once at the end of each test scenario."""
|
206
213
|
|
207
214
|
# To provide the return type in the rendered documentation
|
208
215
|
teardown.__doc__ = RuleBasedStateMachine.teardown.__doc__
|
@@ -212,14 +219,6 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
212
219
|
return self.step(input)
|
213
220
|
|
214
221
|
def step(self, input: StepInput) -> StepOutput:
|
215
|
-
"""A single state machine step.
|
216
|
-
|
217
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
218
|
-
:param previous: Optional result from the previous step and the direction in which this step should be done.
|
219
|
-
|
220
|
-
Schemathesis prepares data, makes a call and validates the received response.
|
221
|
-
It is the most high-level point to extend the testing process. You probably don't need it in most cases.
|
222
|
-
"""
|
223
222
|
__tracebackhide__ = True
|
224
223
|
self.before_call(input.case)
|
225
224
|
kwargs = self.get_call_kwargs(input.case)
|
@@ -229,126 +228,51 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
229
228
|
return StepOutput(response, input.case)
|
230
229
|
|
231
230
|
def before_call(self, case: Case) -> None:
|
232
|
-
"""
|
233
|
-
|
234
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
231
|
+
"""Called before each API operation in the scenario.
|
235
232
|
|
236
|
-
|
237
|
-
|
233
|
+
Args:
|
234
|
+
case: Test case data for the operation.
|
238
235
|
|
239
|
-
.. code-block:: python
|
240
|
-
|
241
|
-
class APIWorkflow(schema.as_state_machine()):
|
242
|
-
def before_call(self, case):
|
243
|
-
case.query = case.query or {}
|
244
|
-
case.query["test"] = "true"
|
245
|
-
|
246
|
-
You can also modify data only for some operations:
|
247
|
-
|
248
|
-
.. code-block:: python
|
249
|
-
|
250
|
-
class APIWorkflow(schema.as_state_machine()):
|
251
|
-
def before_call(self, case):
|
252
|
-
if case.method == "PUT" and case.path == "/items":
|
253
|
-
case.body["is_fake"] = True
|
254
236
|
"""
|
255
237
|
|
256
238
|
def after_call(self, response: Response, case: Case) -> None:
|
257
|
-
"""
|
258
|
-
|
259
|
-
:param response: Response from the application under test.
|
260
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
261
|
-
|
262
|
-
For example, you can log all response statuses by using this hook:
|
263
|
-
|
264
|
-
.. code-block:: python
|
265
|
-
|
266
|
-
import logging
|
239
|
+
"""Called after each API operation in the scenario.
|
267
240
|
|
268
|
-
|
269
|
-
|
241
|
+
Args:
|
242
|
+
response: HTTP response from the operation.
|
243
|
+
case: Test case data that was executed.
|
270
244
|
|
271
|
-
|
272
|
-
class APIWorkflow(schema.as_state_machine()):
|
273
|
-
def after_call(self, response, case):
|
274
|
-
logger.info(
|
275
|
-
"%s %s -> %d",
|
276
|
-
case.method,
|
277
|
-
case.path,
|
278
|
-
response.status_code,
|
279
|
-
)
|
280
|
-
|
281
|
-
|
282
|
-
# POST /users/ -> 201
|
283
|
-
# GET /users/{user_id} -> 200
|
284
|
-
# PATCH /users/{user_id} -> 200
|
285
|
-
# GET /users/{user_id} -> 200
|
286
|
-
# PATCH /users/{user_id} -> 500
|
287
245
|
"""
|
288
246
|
|
289
247
|
def call(self, case: Case, **kwargs: Any) -> Response:
|
290
|
-
"""Make a request to the API.
|
291
|
-
|
292
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
293
|
-
:param kwargs: Keyword arguments that will be passed to the appropriate ``case.call_*`` method.
|
294
|
-
:return: Response from the application under test.
|
295
|
-
|
296
|
-
Note that WSGI/ASGI applications are detected automatically in this method. Depending on the result of this
|
297
|
-
detection the state machine will call the ``call`` method.
|
298
|
-
|
299
|
-
Usually, you don't need to override this method unless you are building a different state machine on top of this
|
300
|
-
one and want to customize the transport layer itself.
|
301
|
-
"""
|
302
248
|
return case.call(**kwargs)
|
303
249
|
|
304
250
|
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
305
|
-
"""
|
306
|
-
|
307
|
-
Mostly they are proxied to the :func:`requests.request` call.
|
251
|
+
"""Returns keyword arguments for the API call.
|
308
252
|
|
309
|
-
:
|
253
|
+
Args:
|
254
|
+
case: Test case being executed.
|
310
255
|
|
311
|
-
|
256
|
+
Returns:
|
257
|
+
Dictionary passed to the `case.call()` method.
|
312
258
|
|
313
|
-
class APIWorkflow(schema.as_state_machine()):
|
314
|
-
def get_call_kwargs(self, case):
|
315
|
-
return {"verify": False}
|
316
|
-
|
317
|
-
The above example disables the server's TLS certificate verification.
|
318
259
|
"""
|
319
260
|
return {}
|
320
261
|
|
321
262
|
def validate_response(
|
322
263
|
self, response: Response, case: Case, additional_checks: list[CheckFunction] | None = None, **kwargs: Any
|
323
264
|
) -> None:
|
324
|
-
"""
|
325
|
-
|
326
|
-
:param response: Response from the application under test.
|
327
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
328
|
-
:param additional_checks: A list of checks that will be run together with the default ones.
|
329
|
-
:raises FailureGroup: If any of the supplied checks failed.
|
330
|
-
|
331
|
-
If you need to change the default checks or provide custom validation rules, you can do it here.
|
332
|
-
|
333
|
-
.. code-block:: python
|
334
|
-
|
335
|
-
def my_check(response, case):
|
336
|
-
... # some assertions
|
337
|
-
|
338
|
-
|
339
|
-
class APIWorkflow(schema.as_state_machine()):
|
340
|
-
def validate_response(self, response, case):
|
341
|
-
case.validate_response(response, checks=(my_check,))
|
265
|
+
"""Validates the API response using configured checks.
|
342
266
|
|
343
|
-
|
344
|
-
|
267
|
+
Args:
|
268
|
+
response: HTTP response to validate.
|
269
|
+
case: Test case that generated the response.
|
270
|
+
additional_checks: Extra validation functions to run.
|
271
|
+
kwargs: Transport-level keyword arguments.
|
345
272
|
|
346
|
-
|
347
|
-
|
273
|
+
Raises:
|
274
|
+
FailureGroup: When validation checks fail.
|
348
275
|
|
349
|
-
**Note** that it is preferred to pass check functions as an argument to ``case.validate_response``.
|
350
|
-
In this case, all checks will be executed, and you'll receive a grouped exception that contains results from
|
351
|
-
all provided checks rather than only the first encountered exception.
|
352
276
|
"""
|
353
277
|
__tracebackhide__ = True
|
354
278
|
case.validate_response(response, additional_checks=additional_checks, transport_kwargs=kwargs)
|
schemathesis/graphql/loaders.py
CHANGED
@@ -19,15 +19,54 @@ if TYPE_CHECKING:
|
|
19
19
|
|
20
20
|
|
21
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
|
+
"""
|
22
40
|
require_relative_url(path)
|
23
41
|
kwargs.setdefault("json", {"query": get_introspection_query()})
|
24
42
|
client = asgi.get_client(app)
|
25
43
|
response = load_from_url(client.post, url=path, **kwargs)
|
26
44
|
schema = extract_schema_from_response(response, lambda r: r.json())
|
27
|
-
|
45
|
+
loaded = from_dict(schema=schema, config=config)
|
46
|
+
loaded.app = app
|
47
|
+
loaded.location = path
|
48
|
+
return loaded
|
28
49
|
|
29
50
|
|
30
51
|
def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> GraphQLSchema:
|
52
|
+
"""Load GraphQL schema from a WSGI application via introspection.
|
53
|
+
|
54
|
+
Args:
|
55
|
+
path: Relative URL path to the GraphQL endpoint (e.g., "/graphql")
|
56
|
+
app: WSGI application instance
|
57
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
58
|
+
**kwargs: Additional request parameters passed to the WSGI test client.
|
59
|
+
|
60
|
+
Example:
|
61
|
+
```python
|
62
|
+
from flask import Flask
|
63
|
+
import schemathesis
|
64
|
+
|
65
|
+
app = Flask(__name__)
|
66
|
+
schema = schemathesis.graphql.from_wsgi("/graphql", app)
|
67
|
+
```
|
68
|
+
|
69
|
+
"""
|
31
70
|
require_relative_url(path)
|
32
71
|
prepare_request_kwargs(kwargs)
|
33
72
|
kwargs.setdefault("json", {"query": get_introspection_query()})
|
@@ -35,31 +74,104 @@ def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None,
|
|
35
74
|
response = client.post(path=path, **kwargs)
|
36
75
|
raise_for_status(response)
|
37
76
|
schema = extract_schema_from_response(response, lambda r: r.json)
|
38
|
-
|
77
|
+
loaded = from_dict(schema=schema, config=config)
|
78
|
+
loaded.app = app
|
79
|
+
loaded.location = path
|
80
|
+
return loaded
|
39
81
|
|
40
82
|
|
41
83
|
def from_url(
|
42
84
|
url: str, *, config: SchemathesisConfig | None = None, wait_for_schema: float | None = None, **kwargs: Any
|
43
85
|
) -> GraphQLSchema:
|
44
|
-
"""Load from URL.
|
86
|
+
"""Load GraphQL schema from a URL via introspection query.
|
87
|
+
|
88
|
+
Args:
|
89
|
+
url: Full URL to the GraphQL endpoint
|
90
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
91
|
+
wait_for_schema: Maximum time in seconds to wait for schema availability
|
92
|
+
**kwargs: Additional parameters passed to `requests.post()` (headers, timeout, auth, etc.).
|
93
|
+
|
94
|
+
Example:
|
95
|
+
```python
|
96
|
+
import schemathesis
|
97
|
+
|
98
|
+
# Basic usage
|
99
|
+
schema = schemathesis.graphql.from_url("https://api.example.com/graphql")
|
100
|
+
|
101
|
+
# With authentication and timeout
|
102
|
+
schema = schemathesis.graphql.from_url(
|
103
|
+
"https://api.example.com/graphql",
|
104
|
+
headers={"Authorization": "Bearer token"},
|
105
|
+
timeout=30,
|
106
|
+
wait_for_schema=10.0
|
107
|
+
)
|
108
|
+
```
|
109
|
+
|
110
|
+
"""
|
45
111
|
import requests
|
46
112
|
|
47
113
|
kwargs.setdefault("json", {"query": get_introspection_query()})
|
48
114
|
response = load_from_url(requests.post, url=url, wait_for_schema=wait_for_schema, **kwargs)
|
49
115
|
schema = extract_schema_from_response(response, lambda r: r.json())
|
50
|
-
|
116
|
+
loaded = from_dict(schema, config=config)
|
117
|
+
loaded.location = url
|
118
|
+
return loaded
|
51
119
|
|
52
120
|
|
53
121
|
def from_path(
|
54
122
|
path: PathLike | str, *, config: SchemathesisConfig | None = None, encoding: str = "utf-8"
|
55
123
|
) -> GraphQLSchema:
|
56
|
-
"""Load from a filesystem path.
|
124
|
+
"""Load GraphQL schema from a filesystem path.
|
125
|
+
|
126
|
+
Args:
|
127
|
+
path: File path to the GraphQL schema file (.graphql, .gql)
|
128
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
129
|
+
encoding: Text encoding for reading the file
|
130
|
+
|
131
|
+
Example:
|
132
|
+
```python
|
133
|
+
import schemathesis
|
134
|
+
|
135
|
+
# Load from GraphQL SDL file
|
136
|
+
schema = schemathesis.graphql.from_path("./schema.graphql")
|
137
|
+
```
|
138
|
+
|
139
|
+
"""
|
57
140
|
with open(path, encoding=encoding) as file:
|
58
|
-
|
141
|
+
loaded = from_file(file=file, config=config)
|
142
|
+
loaded.location = Path(path).absolute().as_uri()
|
143
|
+
return loaded
|
59
144
|
|
60
145
|
|
61
146
|
def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
|
62
|
-
"""Load from file-like object or string.
|
147
|
+
"""Load GraphQL schema from a file-like object or string.
|
148
|
+
|
149
|
+
Args:
|
150
|
+
file: File-like object or raw string containing GraphQL SDL
|
151
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
152
|
+
|
153
|
+
Example:
|
154
|
+
```python
|
155
|
+
import schemathesis
|
156
|
+
|
157
|
+
# From GraphQL SDL string
|
158
|
+
schema_sdl = '''
|
159
|
+
type Query {
|
160
|
+
user(id: ID!): User
|
161
|
+
}
|
162
|
+
type User {
|
163
|
+
id: ID!
|
164
|
+
name: String!
|
165
|
+
}
|
166
|
+
'''
|
167
|
+
schema = schemathesis.graphql.from_file(schema_sdl)
|
168
|
+
|
169
|
+
# From file object
|
170
|
+
with open("schema.graphql") as f:
|
171
|
+
schema = schemathesis.graphql.from_file(f)
|
172
|
+
```
|
173
|
+
|
174
|
+
"""
|
63
175
|
import graphql
|
64
176
|
|
65
177
|
if isinstance(file, str):
|
@@ -87,7 +199,39 @@ def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None)
|
|
87
199
|
|
88
200
|
|
89
201
|
def from_dict(schema: dict[str, Any], *, config: SchemathesisConfig | None = None) -> GraphQLSchema:
|
90
|
-
"""
|
202
|
+
"""Load GraphQL schema from a dictionary containing introspection result.
|
203
|
+
|
204
|
+
Args:
|
205
|
+
schema: Dictionary containing GraphQL introspection result or wrapped in 'data' key
|
206
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
207
|
+
|
208
|
+
Example:
|
209
|
+
```python
|
210
|
+
import schemathesis
|
211
|
+
|
212
|
+
# From introspection result
|
213
|
+
introspection = {
|
214
|
+
"__schema": {
|
215
|
+
"types": [...],
|
216
|
+
"queryType": {"name": "Query"},
|
217
|
+
# ... rest of introspection result
|
218
|
+
}
|
219
|
+
}
|
220
|
+
schema = schemathesis.graphql.from_dict(introspection)
|
221
|
+
|
222
|
+
# From GraphQL response format (with 'data' wrapper)
|
223
|
+
response_data = {
|
224
|
+
"data": {
|
225
|
+
"__schema": {
|
226
|
+
"types": [...],
|
227
|
+
"queryType": {"name": "Query"}
|
228
|
+
}
|
229
|
+
}
|
230
|
+
}
|
231
|
+
schema = schemathesis.graphql.from_dict(response_data)
|
232
|
+
```
|
233
|
+
|
234
|
+
"""
|
91
235
|
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
92
236
|
|
93
237
|
if "data" in schema:
|
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.
|
115
|
+
self.hook = to_filterable_hook(self) # type: ignore[method-assign]
|
119
116
|
|
120
|
-
|
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
|
132
|
-
|
133
|
-
@schemathesis.hook("before_generate_query")
|
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:
|
@@ -248,10 +231,7 @@ class HookDispatcher:
|
|
248
231
|
hook(context, *args, **kwargs)
|
249
232
|
|
250
233
|
def unregister(self, hook: Callable) -> None:
|
251
|
-
"""Unregister a specific hook.
|
252
|
-
|
253
|
-
:param hook: A hook function to unregister.
|
254
|
-
"""
|
234
|
+
"""Unregister a specific hook."""
|
255
235
|
# It removes this function from all places
|
256
236
|
for hooks in self._hooks.values():
|
257
237
|
hooks[:] = [item for item in hooks if item is not hook]
|
@@ -391,6 +371,50 @@ def after_call(context: HookContext, case: Case, response: Response) -> None:
|
|
391
371
|
GLOBAL_HOOK_DISPATCHER = HookDispatcher(scope=HookScope.GLOBAL)
|
392
372
|
dispatch = GLOBAL_HOOK_DISPATCHER.dispatch
|
393
373
|
get_all_by_name = GLOBAL_HOOK_DISPATCHER.get_all_by_name
|
394
|
-
register = GLOBAL_HOOK_DISPATCHER.register
|
395
374
|
unregister = GLOBAL_HOOK_DISPATCHER.unregister
|
396
375
|
unregister_all = GLOBAL_HOOK_DISPATCHER.unregister_all
|
376
|
+
|
377
|
+
|
378
|
+
def hook(hook: str | Callable) -> Callable:
|
379
|
+
"""Register a new hook.
|
380
|
+
|
381
|
+
Args:
|
382
|
+
hook: Either a hook function (autodetecting its name) or a string matching one of the supported hook names.
|
383
|
+
|
384
|
+
Example:
|
385
|
+
Can be used as a decorator in two ways:
|
386
|
+
|
387
|
+
1. Without arguments (auto-detect the hook name from the function name):
|
388
|
+
|
389
|
+
```python
|
390
|
+
@schemathesis.hook
|
391
|
+
def filter_query(ctx, query):
|
392
|
+
\"\"\"Skip cases where query is None or invalid\"\"\"
|
393
|
+
return query and "user_id" in query
|
394
|
+
|
395
|
+
@schemathesis.hook
|
396
|
+
def before_call(ctx, case):
|
397
|
+
\"\"\"Modify headers before sending each request\"\"\"
|
398
|
+
if case.headers is None:
|
399
|
+
case.headers = {}
|
400
|
+
case.headers["X-Test-Mode"] = "true"
|
401
|
+
return None
|
402
|
+
```
|
403
|
+
|
404
|
+
2. With an explicit hook name as the first argument:
|
405
|
+
|
406
|
+
```python
|
407
|
+
@schemathesis.hook("map_headers")
|
408
|
+
def add_custom_header(ctx, headers):
|
409
|
+
\"\"\"Inject a test header into every request\"\"\"
|
410
|
+
if headers is None:
|
411
|
+
headers = {}
|
412
|
+
headers["X-Custom"] = "value"
|
413
|
+
return headers
|
414
|
+
```
|
415
|
+
|
416
|
+
"""
|
417
|
+
return GLOBAL_HOOK_DISPATCHER.hook(hook)
|
418
|
+
|
419
|
+
|
420
|
+
hook.__dict__ = GLOBAL_HOOK_DISPATCHER.hook.__dict__
|