Skip to content

Commit

Permalink
feat: A new generic hooks system
Browse files Browse the repository at this point in the history
  • Loading branch information
Stranger6667 committed May 3, 2020
1 parent add5df5 commit 3dd048e
Show file tree
Hide file tree
Showing 13 changed files with 590 additions and 160 deletions.
53 changes: 0 additions & 53 deletions README.rst
Expand Up @@ -396,59 +396,6 @@ For convenience you can explore the schemas and strategies manually:
Schema instances implement ``Mapping`` protocol.

Changing data generation behavior
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

If you want to customize how data is generated, then you can use hooks of three types:

- Global, which are applied to all schemas;
- Schema-local, which are applied only for specific schema instance;
- Test function specific, they are applied only for a specific test function;

Each hook accepts a Hypothesis strategy and a hook context. Hook context provides additional info that might be helpful to
construct a new strategy, for example ``context.endpoint`` attribute is a reference to the currently tested endpoint.
For more information look at ``schemathesis.hooks.HookContext`` class.

Hooks should return a Hypothesis strategy:

.. code:: python
import schemathesis
def global_hook(strategy, context):
return strategy.filter(lambda x: x["id"].isdigit())
schemathesis.hooks.register("query", hook)
schema = schemathesis.from_uri("http://0.0.0.0:8080/swagger.json")
def schema_hook(strategy, context):
return strategy.filter(lambda x: int(x["id"]) % 2 == 0)
schema.register_hook("query", schema_hook)
def function_hook(strategy, context):
return strategy.filter(lambda x: len(x["id"]) > 5)
@schema.with_hook("query", function_hook)
@schema.parametrize()
def test_api(case):
...
There are 6 places, where hooks can be applied and you need to pass it as the first argument to ``schemathesis.hooks.register`` or ``schema.register_hook``:

- path_parameters
- headers
- cookies
- query
- body
- form_data

It might be useful if you want to exclude certain cases that you don't want to test, or modify the generated data, so it
will be more meaningful for the application - add existing IDs from the database, custom auth header, etc.

**NOTE**. Global hooks are applied first.

Lazy loading
~~~~~~~~~~~~

Expand Down
5 changes: 4 additions & 1 deletion docs/changelog.rst
Expand Up @@ -11,11 +11,14 @@ Added

- ``context`` argument for hook functions to provide an additional context for hooks. A deprecation warning is emitted
for hook functions that do not accept this argument.
- A new hook system that allows generic hook dispatching. It comes with new hook locations. For more details see "Customization" section in our documentation.

Deprecated
~~~~~~~~~~

- Hook functions that do not accept ``context`` as their second argument. They will become not supported in Schemathesis 2.0.
- Hook functions that do not accept ``context`` as their first argument. They will become not supported in Schemathesis 2.0.
- Registering hooks by name and function. Use ``register`` decorators instead, for more details see "Customization" section in our documentation.
- ``BaseSchema.with_hook`` and ``BaseSchema.register_hook``. Use ``BaseSchema.hooks.apply`` and ``BaseSchema.hooks.register`` instead.

Fixed
~~~~~
Expand Down
81 changes: 81 additions & 0 deletions docs/customization.rst
@@ -0,0 +1,81 @@
.. customization:
Customization
=============

Often you need to modify certain aspects of Schemathesis behavior, adjust data generation, modify requests before
sending, and so on. Schemathesis offers a hook mechanism which is similar to the pytest's one.

Basing on the scope of the changes there are three levels of hooks:

- Global. These hooks applied to all schemas in the test run;
- Schema-local. Applied only for specific schema instance;
- Test function specific. Applied only for a specific test function;

To register a new hook function you need to use special decorators - ``register`` for global and schema-local hooks and ``apply`` for test-specific ones:

.. code:: python
import schemathesis
@schemathesis.hooks.register
def before_generate_query(context, strategy):
return strategy.filter(lambda x: x["id"].isdigit())
schema = schemathesis.from_uri("http://0.0.0.0:8080/swagger.json")
@schema.hooks.register("before_generate_query")
def schema_hook(context, strategy):
return strategy.filter(lambda x: int(x["id"]) % 2 == 0)
def function_hook(context, strategy):
return strategy.filter(lambda x: len(x["id"]) > 5)
@schema.hooks.apply("before_generate_query", function_hook)
@schema.parametrize()
def test_api(case):
...
By default ``register`` functions will check the registered hook name to determine when to run it
(see all hook specifications in the section below), but to avoid name collisions you can provide a hook name as an argument to ``register``.

Also, these decorators will check the signature of your hook function to match the specification.
Each hook should accept ``context`` as the first argument, that provides additional context for hook execution.

Hooks registered on the same level will be applied in the order of registration. When there are multiple hooks in the same hook location, then the global ones will be applied first.

Common hooks
------------

These hooks can be applied both in CLI and in-code use cases.

``before_generate_*``
~~~~~~~~~~~~~~~~~~~~~

This is a group of six hooks that share the same purpose - adjust data generation for specific request's part.

- ``before_generate_path_parameters``
- ``before_generate_headers``
- ``before_generate_cookies``
- ``before_generate_query``
- ``before_generate_body``
- ``before_generate_form_data``

They have the same signature that looks like this:

.. code:: python
def before_generate_query(
context: schemathesis.hooks.HookContext,
strategy: hypothesis.strategies.SearchStrategy,
) -> hypothesis.strategies.SearchStrategy:
pass
``strategy`` is a Hypothesis strategy that will generate a certain request part. For example, your endpoint under test
expects ``id`` query parameter that is a number and you'd like to have only values that have at least three occurrences of "1".
Then your hook might look like this:

.. code:: python
def before_generate_query(context, strategy):
return strategy.filter(lambda x: str(x["id"]).count("1") >= 3)
6 changes: 1 addition & 5 deletions docs/index.rst
@@ -1,8 +1,3 @@
.. schemathesis documentation master file, created by
sphinx-quickstart on Fri Jan 13 12:59:16 2017.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to schemathesis's documentation!
========================================

Expand All @@ -14,6 +9,7 @@ Welcome to schemathesis's documentation!
:caption: Contents:

usage
customization
targeted
faq
changelog
Expand Down
43 changes: 19 additions & 24 deletions src/schemathesis/_hypothesis.py
Expand Up @@ -14,7 +14,7 @@
from . import utils
from ._compat import handle_warnings
from .exceptions import InvalidSchema
from .hooks import HookContext, get_hook
from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher
from .models import Case, Endpoint
from .types import Hook

Expand All @@ -23,11 +23,11 @@


def create_test(
endpoint: Endpoint, test: Callable, settings: Optional[hypothesis.settings] = None, seed: Optional[int] = None,
endpoint: Endpoint, test: Callable, settings: Optional[hypothesis.settings] = None, seed: Optional[int] = None
) -> Callable:
"""Create a Hypothesis test."""
hooks = getattr(test, "_schemathesis_hooks", None)
strategy = endpoint.as_strategy(hooks=hooks)
hook_dispatcher = getattr(test, "_schemathesis_hooks", None)
strategy = endpoint.as_strategy(hooks=hook_dispatcher)
wrapped_test = hypothesis.given(case=strategy)(test)
if seed is not None:
wrapped_test = hypothesis.seed(seed)(wrapped_test)
Expand Down Expand Up @@ -121,7 +121,7 @@ def is_valid_query(query: Dict[str, Any]) -> bool:
return True


def get_case_strategy(endpoint: Endpoint, hooks: Optional[Dict[str, Hook]] = None) -> st.SearchStrategy:
def get_case_strategy(endpoint: Endpoint, hooks: Optional[HookDispatcher] = None) -> st.SearchStrategy:
"""Create a strategy for a complete test case.
Path & endpoint are static, the others are JSON schemas.
Expand Down Expand Up @@ -160,11 +160,7 @@ def filter_path_parameters(parameters: Dict[str, Any]) -> bool:
Because of it this case doesn't bring much value and might lead to false positives results of Schemathesis runs.
"""

path_parameter_blacklist = (
".",
SLASH,
"",
)
path_parameter_blacklist = (".", SLASH, "")

return not any(
(value in path_parameter_blacklist or isinstance(value, str) and SLASH in value)
Expand All @@ -180,7 +176,7 @@ def _get_case_strategy(
endpoint: Endpoint,
extra_static_parameters: Dict[str, Any],
strategies: Dict[str, st.SearchStrategy],
hooks: Optional[Dict[str, Hook]] = None,
hook_dispatcher: Optional[HookDispatcher] = None,
) -> st.SearchStrategy:
static_parameters: Dict[str, Any] = {"endpoint": endpoint, **extra_static_parameters}
if endpoint.schema.validate_schema and endpoint.method == "GET":
Expand All @@ -189,29 +185,28 @@ def _get_case_strategy(
static_parameters["body"] = None
strategies.pop("body", None)
context = HookContext(endpoint)
_apply_hooks(strategies, get_hook, context)
_apply_hooks(strategies, endpoint.schema.get_hook, context)
if hooks is not None:
_apply_hooks(strategies, hooks.get, context)
_apply_hooks(strategies, GLOBAL_HOOK_DISPATCHER, context)
_apply_hooks(strategies, endpoint.schema.hooks, context)
if hook_dispatcher is not None:
_apply_hooks(strategies, hook_dispatcher, context)
return st.builds(partial(Case, **static_parameters), **strategies)


def _apply_hooks(
strategies: Dict[str, st.SearchStrategy], getter: Callable[[str], Optional[Hook]], context: HookContext
) -> None:
for key, strategy in strategies.items():
hook = getter(key)
if hook is not None:
args: Union[Tuple[st.SearchStrategy], Tuple[st.SearchStrategy, HookContext]]
def _apply_hooks(strategies: Dict[str, st.SearchStrategy], dispatcher: HookDispatcher, context: HookContext) -> None:
for key in strategies:
for hook in dispatcher.get_hooks(f"before_generate_{key}"):
# Get the strategy on each hook to pass the first hook output as an input to the next one
strategy = strategies[key]
args: Union[Tuple[st.SearchStrategy], Tuple[HookContext, st.SearchStrategy]]
if _accepts_context(hook):
args = (strategy, context)
args = (context, strategy)
else:
args = (strategy,)
strategies[key] = hook(*args)


def _accepts_context(hook: Hook) -> bool:
# There are no restrictions on the second argument's name and we don't check its name here.
# There are no restrictions on the first argument's name and we don't check its name here.
return len(inspect.signature(hook).parameters) == 2


Expand Down

0 comments on commit 3dd048e

Please sign in to comment.