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.
Files changed (73) hide show
  1. schemathesis/__init__.py +35 -27
  2. schemathesis/auths.py +85 -54
  3. schemathesis/checks.py +65 -36
  4. schemathesis/cli/commands/run/__init__.py +32 -27
  5. schemathesis/cli/commands/run/context.py +6 -1
  6. schemathesis/cli/commands/run/events.py +7 -1
  7. schemathesis/cli/commands/run/executor.py +12 -7
  8. schemathesis/cli/commands/run/handlers/output.py +188 -80
  9. schemathesis/cli/commands/run/validation.py +21 -6
  10. schemathesis/cli/constants.py +1 -1
  11. schemathesis/config/__init__.py +2 -1
  12. schemathesis/config/_generation.py +12 -13
  13. schemathesis/config/_operations.py +14 -0
  14. schemathesis/config/_phases.py +41 -5
  15. schemathesis/config/_projects.py +33 -1
  16. schemathesis/config/_report.py +6 -2
  17. schemathesis/config/_warnings.py +25 -0
  18. schemathesis/config/schema.json +49 -1
  19. schemathesis/core/errors.py +15 -19
  20. schemathesis/core/transport.py +117 -2
  21. schemathesis/engine/context.py +1 -0
  22. schemathesis/engine/errors.py +61 -2
  23. schemathesis/engine/events.py +10 -2
  24. schemathesis/engine/phases/probes.py +3 -0
  25. schemathesis/engine/phases/stateful/__init__.py +2 -1
  26. schemathesis/engine/phases/stateful/_executor.py +38 -5
  27. schemathesis/engine/phases/stateful/context.py +2 -2
  28. schemathesis/engine/phases/unit/_executor.py +36 -7
  29. schemathesis/generation/__init__.py +0 -3
  30. schemathesis/generation/case.py +153 -28
  31. schemathesis/generation/coverage.py +1 -1
  32. schemathesis/generation/hypothesis/builder.py +43 -19
  33. schemathesis/generation/metrics.py +93 -0
  34. schemathesis/generation/modes.py +0 -8
  35. schemathesis/generation/overrides.py +11 -27
  36. schemathesis/generation/stateful/__init__.py +17 -0
  37. schemathesis/generation/stateful/state_machine.py +32 -108
  38. schemathesis/graphql/loaders.py +152 -8
  39. schemathesis/hooks.py +63 -39
  40. schemathesis/openapi/checks.py +82 -20
  41. schemathesis/openapi/generation/filters.py +9 -2
  42. schemathesis/openapi/loaders.py +134 -8
  43. schemathesis/pytest/lazy.py +4 -31
  44. schemathesis/pytest/loaders.py +24 -0
  45. schemathesis/pytest/plugin.py +38 -6
  46. schemathesis/schemas.py +161 -94
  47. schemathesis/specs/graphql/scalars.py +37 -3
  48. schemathesis/specs/graphql/schemas.py +18 -9
  49. schemathesis/specs/openapi/_hypothesis.py +53 -34
  50. schemathesis/specs/openapi/checks.py +111 -47
  51. schemathesis/specs/openapi/expressions/nodes.py +1 -1
  52. schemathesis/specs/openapi/formats.py +30 -3
  53. schemathesis/specs/openapi/media_types.py +44 -1
  54. schemathesis/specs/openapi/negative/__init__.py +5 -3
  55. schemathesis/specs/openapi/negative/mutations.py +2 -2
  56. schemathesis/specs/openapi/parameters.py +0 -3
  57. schemathesis/specs/openapi/schemas.py +14 -93
  58. schemathesis/specs/openapi/stateful/__init__.py +2 -1
  59. schemathesis/specs/openapi/stateful/links.py +1 -63
  60. schemathesis/transport/__init__.py +54 -16
  61. schemathesis/transport/prepare.py +31 -7
  62. schemathesis/transport/requests.py +21 -9
  63. schemathesis/transport/serialization.py +0 -4
  64. schemathesis/transport/wsgi.py +15 -8
  65. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
  66. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
  67. schemathesis/contrib/__init__.py +0 -9
  68. schemathesis/contrib/openapi/__init__.py +0 -9
  69. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  70. schemathesis/generation/targets.py +0 -69
  71. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
  72. {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
  73. {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
- """The base class for state machines generated from API schemas.
132
+ """State machine for executing API operation sequences based on OpenAPI links.
133
133
 
134
- Exposes additional extension points in the testing process.
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
- """Run state machine as a test."""
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
- """Hook method that runs unconditionally in the beginning of each test scenario."""
209
+ """Called once at the beginning of each test scenario."""
203
210
 
204
211
  def teardown(self) -> None:
205
- pass
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
- """Hook method for modifying the case data before making a request.
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
- Use it if you want to inject static data, for example,
237
- a query parameter that should always be used in API calls:
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
- """Hook method for additional actions with case or response instances.
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
- logger = logging.getLogger(__file__)
269
- logger.setLevel(logging.INFO)
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
- """Create custom keyword arguments that will be passed to the :meth:`Case.call` method.
306
-
307
- Mostly they are proxied to the :func:`requests.request` call.
251
+ """Returns keyword arguments for the API call.
308
252
 
309
- :param Case case: Generated test case data that should be sent in an API call to the tested API operation.
253
+ Args:
254
+ case: Test case being executed.
310
255
 
311
- .. code-block:: python
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
- """Validate an API response.
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
- The state machine from the example above will execute only the ``my_check`` check instead of all
344
- available checks.
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
- Each check function should accept ``response`` as the first argument and ``case`` as the second one and raise
347
- ``AssertionError`` if the check fails.
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)
@@ -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
- return from_dict(schema=schema, config=config).configure(app=app, location=path)
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
- return from_dict(schema=schema, config=config).configure(app=app, location=path)
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
- return from_dict(schema, config=config).configure(location=url)
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
- return from_file(file=file, config=config).configure(location=Path(path).absolute().as_uri())
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
- """Base loader that others build upon."""
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
- :ivar Optional[APIOperation] operation: API operation that is currently being processed.
43
- Might be absent in some cases.
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.register = to_filterable_hook(self) # type: ignore[method-assign]
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
- 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
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
- :param hook: A hook function.
143
- :param Optional[str] name: A hook name.
144
-
145
- .. code-block:: python
123
+ Args:
124
+ hook: A hook function.
125
+ name: A hook name.
146
126
 
147
- def before_generate_query(context, strategy):
127
+ Example:
128
+ ```python
129
+ def filter_query(ctx, value):
148
130
  ...
149
131
 
150
132
 
151
- @schema.hooks.apply(before_generate_query)
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__