Skip to content

Commit

Permalink
Tinctures (#844)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelboulton committed Oct 22, 2023
1 parent 68f51ec commit 74d70db
Show file tree
Hide file tree
Showing 13 changed files with 444 additions and 10 deletions.
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ Run every so often to update the pre-commit hooks
black tavern/ tests/
ruff --fix tavern/ tests/

### Fix yaml formatting issues

pre-commit run --all-files

## Creating a new release

1. Setup `~/.pypirc`
Expand Down
75 changes: 74 additions & 1 deletion docs/source/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -2381,6 +2381,79 @@ def pytest_tavern_beta_before_every_request(request_args):
logging.info("Making request: %s", request_args)
```

## Tinctures

Another way of running functions at certain times is to use the 'tinctures' functionality:

```python
# package/helpers.py

import logging
import time

logger = logging.getLogger(__name__)


def time_request(stage):
t0 = time.time()
yield
t1 = time.time()
logger.info("Request for stage %s took %s", stage, t1 - t0)


def print_response(_, extra_print="affa"):
logger.info("STARTING:")
(expected, response) = yield
logger.info("Response is %s (%s)", response, extra_print)
```

```yaml
---
test_name: Test tincture

tinctures:
- function: package.helpers:time_request

stages:
- name: Make a request
tinctures:
- function: package.helpers:print_response
extra_kwargs:
extra_print: "blooble"
request:
url: "{host}/echo"
method: POST
json:
value: "one"

- name: Make another request
request:
url: "{host}/echo"
method: POST
json:
value: "two"
```

Tinctures can be specified on a per-stage level or a per-test level. When specified on the test level, the tincture is
run for every stage in the test. In the above example, the `time_request` function will be run for both stages, but
the 'print_response' function will only be run for the first stage.

Tinctures are _similar_ to fixtures but are more similar to [external functions](#calling-external-functions). Tincture
functions do not need to be annotated with a function like Pytest fixtures, and are referred to in the same
way (`path.to.package:function`), and have arguments passed to them in the same way (`extra_kwargs`, `extra_args`) as
external functions.

The first argument to a tincture is always a dictionary of the stage to be run.

If a tincture has a `yield` in the middle of it, during the `yield` the stage itself will be run. If a return value is
expected from the `yield` (eg `(expected, response) = yield` in the example above) then the _expected_ return values and
the response object from the stage will be returned. This allows a tincture to introspect the response, and compare it
against the expected, the same as the `pytest_tavern_beta_after_every_response` [hook](#after-every-response). This
response object will be different for MQTT and HTTP tests!

If you need to run something before _every_ stage or after _every_ response in your test suite, look at using
the [hooks](#hooks) instead.

## Finalising stages

If you need a stage to run after a test runs, whether it passes or fails (for example, to log out of a service or
Expand All @@ -2396,7 +2469,7 @@ stages:

- name: stage 2
...

- name: stage 3
...

Expand Down
16 changes: 16 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@ docutils==0.18.1 \
# sphinx
# sphinx-rtd-theme
# tavern (pyproject.toml)
exceptiongroup==1.1.3 \
--hash=sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9 \
--hash=sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3
# via pytest
execnet==2.0.2 \
--hash=sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41 \
--hash=sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af
Expand Down Expand Up @@ -930,6 +934,18 @@ stevedore==4.1.1 \
--hash=sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a \
--hash=sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e
# via tavern (pyproject.toml)
tomli==2.0.1 \
--hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \
--hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f
# via
# build
# coverage
# mypy
# pip-tools
# pyproject-api
# pyproject-hooks
# pytest
# tox
tomli-w==1.0.0 \
--hash=sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463 \
--hash=sha256:f463434305e0336248cac9c2dc8076b707d8a12d019dd349f5c1e382dd1ae1b9
Expand Down
6 changes: 3 additions & 3 deletions tavern/_core/dict_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def format_keys(
in broken output, only use for debugging purposes.
Returns:
str,int,list,dict: recursively formatted values
recursively formatted values
"""
formatted = val

Expand All @@ -131,7 +131,7 @@ def format_keys(
for key in val:
formatted[key] = format_keys_(val[key], box_vars)
elif isinstance(val, (list, tuple)):
formatted = [format_keys_(item, box_vars) for item in val]
formatted = [format_keys_(item, box_vars) for item in val] # type: ignore
elif isinstance(formatted, FormattedString):
logger.debug("Already formatted %s, not double-formatting", formatted)
elif isinstance(val, str):
Expand All @@ -142,7 +142,7 @@ def format_keys(
raise

if no_double_format:
formatted = FormattedString(formatted)
formatted = FormattedString(formatted) # type: ignore
elif isinstance(val, TypeConvertToken):
logger.debug("Got type convert token '%s'", val)
if isinstance(val, ForceIncludeToken):
Expand Down
16 changes: 14 additions & 2 deletions tavern/_core/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from .report import attach_stage_content, wrap_step
from .strtobool import strtobool
from .testhelpers import delay, retry
from .tincture import Tinctures, get_stage_tinctures

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -276,6 +277,8 @@ class _TestRunner:
test_spec: Mapping

def run_stage(self, idx: int, stage, *, is_final: bool = False):
tinctures = get_stage_tinctures(stage, self.test_spec)

stage_config = self.test_block_config.with_strictness(
self.default_global_strictness
)
Expand All @@ -284,7 +287,9 @@ def run_stage(self, idx: int, stage, *, is_final: bool = False):
)
# Wrap run_stage with retry helper
run_stage_with_retries = retry(stage, stage_config)(self.wrapped_run_stage)
partial = functools.partial(run_stage_with_retries, stage, stage_config)
partial = functools.partial(
run_stage_with_retries, stage, stage_config, tinctures
)
allure_name = "Stage {}: {}".format(
idx, format_keys(stage["name"], stage_config.variables)
)
Expand All @@ -298,12 +303,15 @@ def run_stage(self, idx: int, stage, *, is_final: bool = False):
e.is_final = is_final
raise

def wrapped_run_stage(self, stage: dict, stage_config: TestConfig):
def wrapped_run_stage(
self, stage: dict, stage_config: TestConfig, tinctures: Tinctures
):
"""Run one stage from the test
Args:
stage: specification of stage to be run
stage_config: available variables for test
tinctures: tinctures for this stage/test
"""
stage = copy.deepcopy(stage)
name = stage["name"]
Expand All @@ -329,8 +337,12 @@ def wrapped_run_stage(self, stage: dict, stage_config: TestConfig):

verifiers = get_verifiers(stage, stage_config, self.sessions, expected)

tinctures.start_tinctures(stage)

response = r.run()

tinctures.end_tinctures(expected, response)

for response_type, response_verifiers in verifiers.items():
logger.debug("Running verifiers for %s", response_type)
for v in response_verifiers:
Expand Down
12 changes: 12 additions & 0 deletions tavern/_core/schema/tests.jsonschema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ definitions:
- name

properties:
tinctures:
type: array
description: Tinctures for stage
items:
$ref: "#/definitions/verify_block"

id:
type: string
description: ID of stage for use in stage references
Expand Down Expand Up @@ -415,6 +421,12 @@ properties:
- key
- vals

tinctures:
type: array
description: Tinctures for whole test
items:
$ref: "#/definitions/verify_block"

strict:
$ref: "#/definitions/strict_block"

Expand Down
4 changes: 4 additions & 0 deletions tavern/_core/schema/tests.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ schema;stage:
required: true
func: verify_oneof_id_name
mapping:
tinctures:
func: validate_extensions
type: any

id:
type: str
required: false
Expand Down
8 changes: 4 additions & 4 deletions tavern/_core/testhelpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
logger = logging.getLogger(__name__)


def delay(stage, when, variables) -> None:
def delay(stage: Mapping, when: str, variables: Mapping) -> None:
"""Look for delay_before/delay_after and sleep
Args:
stage (dict): test stage
when (str): 'before' or 'after'
variables (dict): Variables to format with
stage: test stage
when: 'before' or 'after'
variables: Variables to format with
"""

try:
Expand Down
81 changes: 81 additions & 0 deletions tavern/_core/tincture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import collections.abc
import inspect
import logging
from typing import Any, List

from tavern._core import exceptions
from tavern._core.extfunctions import get_wrapped_response_function

logger = logging.getLogger(__name__)


class Tinctures:
def __init__(self, tinctures: List[Any]):
self._tinctures = tinctures
self._needs_response: List[Any] = []

def start_tinctures(self, stage: collections.abc.Mapping):
results = [t(stage) for t in self._tinctures]
self._needs_response = []

for r in results:
if inspect.isgenerator(r):
# Store generator and start it
self._needs_response.append(r)
next(r)

def end_tinctures(self, expected: collections.abc.Mapping, response) -> None:
"""
Send the response object to any tinctures that want it
Args:
response: The response from 'run' for the stage
"""
if self._needs_response is None:
raise RuntimeError(
"should not be called before accumulating tinctures which need a response"
)

for n in self._needs_response:
try:
n.send((expected, response))
except StopIteration:
pass
else:
raise RuntimeError("Tincture had more than one yield")


def get_stage_tinctures(
stage: collections.abc.Mapping, test_spec: collections.abc.Mapping
) -> Tinctures:
"""Get tinctures for stage
Args:
stage: Stage
test_spec: Whole test spec
"""
stage_tinctures = []

def add_tinctures_from_block(maybe_tinctures, blockname: str):
logger.debug("Trying to add tinctures from %s", blockname)

def inner_yield():
if maybe_tinctures is not None:
if isinstance(maybe_tinctures, list):
for vf in maybe_tinctures:
yield get_wrapped_response_function(vf)
elif isinstance(maybe_tinctures, dict):
yield get_wrapped_response_function(maybe_tinctures)
elif maybe_tinctures is not None:
raise exceptions.BadSchemaError(
"Badly formatted 'tinctures' block in {}".format(blockname)
)

stage_tinctures.extend(inner_yield())

add_tinctures_from_block(test_spec.get("tinctures"), "test")
add_tinctures_from_block(stage.get("tinctures"), "stage")

logger.debug("%d tinctures for stage %s", len(stage_tinctures), stage["name"])

return Tinctures(stage_tinctures)
13 changes: 13 additions & 0 deletions tests/integration/ext_functions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import time


def return_hello():
return {"hello": "there"}

Expand All @@ -12,3 +15,13 @@ def return_list_vals():

def gen_echo_url(host):
return "{0}/echo".format(host)


def time_request(_):
time.time()
yield
time.time()


def print_response(_, extra_print="affa"):
(_, r) = yield

0 comments on commit 74d70db

Please sign in to comment.