Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
include tavern/_core/schema/tests.jsonschema.yaml
include tavern/_plugins/mqtt/jsonschema.yaml
include tavern/_plugins/rest/jsonschema.yaml
include tavern/_plugins/grpc/schema.yaml
include LICENSE
66 changes: 66 additions & 0 deletions example/custom_backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Tavern Custom Backend Plugin

This example demonstrates how to create a custom backend plugin for Tavern, a pytest plugin for API testing. The custom
backend allows you to extend Tavern's functionality with your own request/response handling logic.

## Overview

This example plugin implements a simple file touch/verification system:

- `touch_file` stage: Creates or updates a file timestamp (similar to the Unix `touch` command)
- `file_exists` stage: Verifies that a specified file exists

## Implementation Details

This example includes:

- `Request` class: Extends `tavern.request.BaseRequest` and implements the `request_vars` property and `run()` method
- `Response` class: Extends `tavern.response.BaseResponse` and implements the `verify()` method
- `Session` class: Context manager for maintaining any state
- `get_expected_from_request` function: Optional function to generate expected response from request
- `jsonschema.yaml`: Schema validation for request/response objects
- `schema_path`: Path to the schema file for validation

## Entry Point Configuration

In your project's `pyproject.toml`, configure the plugin entry point:

```toml
[project.entry-points.tavern_your_backend_name]
my_implementation = 'your.package.path:your_backend_module'
```

Then when running tests, specify the extra backend:

```bash
pytest --tavern-extra-backends=your_backend_name
# Or, to specify an implementation to override the project entrypoint:
pytest --tavern-extra-backends=your_backend_name=my_other_implementation
```

Or the equivalent in pyproject.toml or pytest.ini. Note:

- The entry point name should start with `tavern_`.
- The key of the entrypoint is just a name of the implementation and can be anything.
- The `--tavern-extra-backends` flag should *not* be prefixed with `tavern_`.
- If Tavern detects multiple entrypoints for a backend, it will raise an error. In this case, you must use the second
form to specify which implementation of the backend to use. This is similar to the build-in `--tavern-http-backend`
flag.

This is because Tavern by default only tries to load "grpc", "http" and "mqtt" backends. The flag registers the custom
backend with Tavern, which can then tell [stevedore](https://github.com/openstack/stevedore) to load the plugin from the
entrypoint.

## Example Test

```yaml
---
test_name: Test file touched

stages:
- name: Touch file and check it exists
touch_file:
filename: hello.txt
file_exists:
filename: hello.txt
```
Empty file.
39 changes: 39 additions & 0 deletions example/custom_backend/my_tavern_plugin/jsonschema.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
$schema: "http://json-schema.org/draft-07/schema#"

title: file touch schema
description: Schema for touching files

###

definitions:
touch_file:
type: object
description: touch a file
additionalProperties: false
required:
- filename

properties:
filename:
type: string
description: Name of file to touch

file_exists:
type: object
description: name of file which should exist
additionalProperties: false
required:
- filename

properties:
filename:
type: string
description: Name of file to check for

stage:
properties:
touch_file:
$ref: "#/definitions/touch_file"

file_exists:
$ref: "#/definitions/file_exists"
83 changes: 83 additions & 0 deletions example/custom_backend/my_tavern_plugin/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import logging
import pathlib
from collections.abc import Iterable
from os.path import abspath, dirname, join
from typing import Any, Optional, Union

import box
import yaml

from tavern._core import exceptions
from tavern._core.pytest.config import TestConfig
from tavern.request import BaseRequest
from tavern.response import BaseResponse


class Session:
"""No-op session, but must implement the context manager protocol"""
def __enter__(self):
pass

def __exit__(self, exc_type, exc_val, exc_tb):
pass


class Request(BaseRequest):
"""Touches a file when the 'request' is made"""
def __init__(
self, session: Any, rspec: dict, test_block_config: TestConfig
) -> None:
self.session = session

self._request_vars = rspec

@property
def request_vars(self) -> box.Box:
return self._request_vars

def run(self):
pathlib.Path(self._request_vars["filename"]).touch()


class Response(BaseResponse):
def verify(self, response):
if not pathlib.Path(self.expected["filename"]).exists():
raise exceptions.BadSchemaError(
f"Expected file '{self.expected['filename']}' does not exist"
)

return {}

def __init__(
self,
client,
name: str,
expected: TestConfig,
test_block_config: TestConfig,
) -> None:
super().__init__(name, expected, test_block_config)


logger: logging.Logger = logging.getLogger(__name__)

session_type = Session

request_type = Request
request_block_name = "touch_file"


verifier_type = Response
response_block_name = "file_exists"


def get_expected_from_request(
response_block: Union[dict, Iterable[dict]],
test_block_config: TestConfig,
session: Session,
) -> Optional[dict]:
return response_block


schema_path: str = join(abspath(dirname(__file__)), "jsonschema.yaml")
with open(schema_path, encoding="utf-8") as schema_file:
schema = yaml.load(schema_file, Loader=yaml.SafeLoader)
10 changes: 10 additions & 0 deletions example/custom_backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[project]
name = "my_tavern_plugin"
version = "0.1.0"
description = "A custom 'generic' plugin for tavern that touches files and checks if they are created."
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

[project.entry-points.tavern_file]
my_tavern_plugin = "my_tavern_plugin.plugin"
25 changes: 25 additions & 0 deletions example/custom_backend/run_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/bash

set -ex

if [ ! -d ".venv" ]; then
uv venv
fi
. .venv/bin/activate

uv pip install -e . 'tavern @ ../..'

PYTHONPATH=. tavern-ci \
--tavern-extra-backends=file \
--debug "$@" --stdout \
tests

PYTHONPATH=. tavern-ci \
--tavern-extra-backends=file=my_tavern_plugin \
--debug "$@" --stdout \
tests

PYTHONPATH=. tavern-ci \
--tavern-extra-backends=file=i_dont_exist \
--debug "$@" --stdout \
tests
46 changes: 46 additions & 0 deletions example/custom_backend/tests/test_file_touched.tavern.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
test_name: Test file touched

stages:
- name: Touch file and check it exists
touch_file:
filename: hello.txt
file_exists:
filename: hello.txt

---
test_name: Test file touched - should fail because file doesn't exist

marks:
- xfail

stages:
- name: Touch file that doesn't exist
touch_file:
filename: some_other_file.txt
file_exists:
filename: nonexistent_file.txt

---
test_name: Test with invalid schema - should fail

_xfail: verify

stages:
- name: Test invalid touch_file schema
touch_file:
nonexistent_field: some_value
file_exists:
filename: hello.txt

---
test_name: Test with invalid response schema - should fail

_xfail: verify

stages:
- name: Test invalid file_exists schema
touch_file:
filename: hello.txt
file_exists:
nonexistent_field: some_value
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,4 @@ cmd = "uv lock"
runner = "uv-venv-lock-runner"
skip_missing_interpreters = true
isolated_build = true
base_python = "3.11"
base_python = "3.11"
50 changes: 44 additions & 6 deletions tavern/_core/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Any, Optional, Protocol

import stevedore
import stevedore.extension

from tavern._core import exceptions
from tavern._core.dict_util import format_keys
Expand Down Expand Up @@ -125,20 +126,51 @@ def _load_plugins(self, test_block_config: TestConfig) -> list[_Plugin]:
"""

plugins = []
discovered_plugins: dict[str, list[str]] = {}

def is_plugin_backend_enabled(
current_backend: str, ext: stevedore.extension.Extension
) -> bool:
"""Checks if a plugin backend is enabled based on configuration.

If no specific backend is configured, defaults to enabled.
Adds enabled plugins to discovered_plugins tracking dictionary.

Args:
current_backend: The backend being checked (e.g. 'http', 'mqtt')
ext: The stevedore extension object representing the plugin

Returns:
Whether the plugin backend is enabled
"""
if test_block_config.tavern_internal.backends[current_backend] is None:
# Use whatever default - will raise an error if >1 is discovered
is_enabled = True
logger.debug(f"Using default backend for {ext.name}")
else:
is_enabled = (
ext.name
== test_block_config.tavern_internal.backends[current_backend]
)
logger.debug(
f"Is {current_backend} for {ext.name} enabled? {is_enabled}"
)

def enabled(current_backend, ext):
return (
ext.name == test_block_config.tavern_internal.backends[current_backend]
)
if is_enabled:
if current_backend not in discovered_plugins:
discovered_plugins[current_backend] = []
discovered_plugins[current_backend].append(ext.name)

for backend in test_block_config.backends():
return is_enabled

for backend in test_block_config.tavern_internal.backends.keys():
logger.debug("loading backend for %s", backend)

namespace = f"tavern_{backend}"

manager = stevedore.EnabledExtensionManager(
namespace=namespace,
check_func=partial(enabled, backend),
check_func=partial(is_plugin_backend_enabled, backend),
verify_requirements=True,
on_load_failure_callback=plugin_load_error,
)
Expand All @@ -153,6 +185,12 @@ def enabled(current_backend, ext):

plugins.extend(manager.extensions)

for plugin, enabled in discovered_plugins.items():
if len(enabled) > 1:
raise exceptions.PluginLoadError(
f"Multiple plugins enabled for '{plugin}' backend: {enabled}"
)

return plugins


Expand Down
2 changes: 1 addition & 1 deletion tavern/_core/pytest/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def backends() -> list[str]:

if has_module("paho.mqtt"):
available_backends.append("mqtt")
if has_module("grpc"):
if has_module("grpc") and has_module("grpc_reflection"):
available_backends.append("grpc")

logger.debug(f"available request backends: {available_backends}")
Expand Down
Loading