Skip to content

Instantly share code, notes, and snippets.

@oakkitten
Last active January 20, 2021 22:37
Show Gist options
  • Save oakkitten/f254af53d42344d72d78853543bf7d13 to your computer and use it in GitHub Desktop.
Save oakkitten/f254af53d42344d72d78853543bf7d13 to your computer and use it in GitHub Desktop.
A bit crazy parameter passing with pytest
from plugin import parameters, arguments, Fixture
from plugin import fixture_taking_arguments as fixture
from types import SimpleNamespace
from functools import partial
from dataclasses import dataclass
import pytest
from _pytest.python import CallSpec2
def flatten(seq):
return [item for subseq in seq for item in subseq]
def get_fixture_value_without_arguments(name, request, monkeypatch):
return request.getfixturevalue(name)
# this is very hacky and probably can be done in a much saner way
def get_fixture_value_with_arguments(name, arguments, request, monkeypatch):
try:
callspec = request._pyfuncitem.callspec
except:
callspec = CallSpec2(None)
monkeypatch.setattr(request._pyfuncitem, "callspec", CallSpec2(None))
monkeypatch.setitem(callspec.indices, name, 0)
monkeypatch.setitem(callspec.params, name, arguments)
return request.getfixturevalue(name)
# takes a source (a fixture, a parametrized fixture,
# argumens/argumentize call result, or any other object)
# an yields callables that take request and monkeypatch and return values
def get_fixture_value_getters(source):
if hasattr(source, "_pytestfixturefunction"):
name = Fixture.from_anything(source).name
params = source._pytestfixturefunction.params
if not params:
yield partial(get_fixture_value_without_arguments, name)
else:
for param in params:
yield partial(get_fixture_value_with_arguments, name, param)
elif hasattr(source, "_metadata"):
parameters = source._metadata.parameters
list_of_argument_sets = list(source._metadata.iterable_of_argument_sets)
if len(parameters) != 1:
raise ValueError("Only one parameter can be argumentized")
name = Fixture.from_anything(parameters[0]).name
for argument_set in list_of_argument_sets:
yield partial(get_fixture_value_with_arguments, name, argument_set[0])
else:
yield lambda *args: source
def create_sync(name, **pytest_kwargs):
def argumentize(*sources):
source_getters = flatten(
list(get_fixture_value_getters(source))
for source in sources
)
@fixture.literal("source_getter").argumentize(*source_getters)
@fixture(name=name, **pytest_kwargs)
def fixture_function(request, monkeypatch, /, source_getter):
return source_getter(request, monkeypatch)
return fixture_function
return SimpleNamespace(argumentize=argumentize)
import pytest
from dataclasses import dataclass
from functools import partial, wraps
from inspect import signature, Parameter, isgeneratorfunction
from itertools import zip_longest, chain
from types import SimpleNamespace
import attr
__all__ = (
"fixture_taking_arguments",
"parameters",
"arguments",
)
NOTHING = object()
ParameterSet = type(pytest.param()) # why does pytest call this "parameter"?
def omittable_parentheses(maybe_decorator=None, /, allow_partial=False):
"""A decorator for decorators that allows them to be used without parentheses"""
def decorator(func):
@wraps(decorator)
def wrapper(*args, **kwargs):
if len(args) == 1 and callable(args[0]):
if allow_partial:
return func(**kwargs)(args[0])
elif not kwargs:
return func()(args[0])
return func(*args, **kwargs)
return wrapper
if maybe_decorator is None:
return decorator
else:
return decorator(maybe_decorator)
def get_pytest_fixture_name(fixture):
"""Get the name of a pytest fixture,
either the one passed to @fixture(name=...)
or using the name of the fixture function itself"""
if fixture._pytestfixturefunction.name is not None:
return fixture._pytestfixturefunction.name
else:
return fixture.__name__
class Arguments:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def __repr__(self):
return ", ".join(chain(
(repr(v) for v in self.args),
(f"{k}={v!r}" for k, v in self.kwargs.items())))
def positionalize_arguments(sig, args, kwargs):
assert len(sig.parameters) == len(args) + len(kwargs)
for name, arg in zip_longest(sig.parameters, args, fillvalue=NOTHING):
yield arg if arg is not NOTHING else kwargs[name]
@omittable_parentheses
def fixture_taking_arguments(*pytest_fixture_args, **pytest_fixture_kwargs):
def decorator(func):
original_signature = signature(func)
def new_parameters():
for param in original_signature.parameters.values():
if param.kind == Parameter.POSITIONAL_ONLY:
yield param.replace(kind=Parameter.POSITIONAL_OR_KEYWORD)
new_signature = original_signature.replace(parameters=list(new_parameters()))
if "request" not in new_signature.parameters:
raise ValueError("Target function must have positional-only argument `request`")
@wraps(func)
def wrapper(*args, **kwargs):
request = kwargs["request"]
try:
request_args, request_kwargs = request.param.args, request.param.kwargs
except AttributeError:
try:
request_args, request_kwargs = (request.param,), {}
except AttributeError:
request_args, request_kwargs = (), {}
result = func(*positionalize_arguments(new_signature, args, kwargs),
*request_args, **request_kwargs)
if isgeneratorfunction(func):
yield from result
else:
yield result
wrapper.__signature__ = new_signature
fixture = pytest.fixture(*pytest_fixture_args, **pytest_fixture_kwargs)(wrapper)
fixture.arguments = Fixture.from_anything(fixture).arguments
fixture.argumentize = Fixture.from_anything(fixture).argumentize
return fixture
return decorator
def tuplify(arg):
return arg if isinstance(arg, ParameterSet) else (arg,)
class Base:
def argumentize(self, *iterable_of_Arguments):
return parameters(self).argumentize(
*(tuplify(Arguments) for Arguments in iterable_of_Arguments)
)
@dataclass
class Fixture(Base):
name: ...
def arguments(self, *args, **kwargs):
return self.argumentize(Arguments(*args, **kwargs))
@classmethod
def from_anything(cls, thing):
name = thing.name if isinstance(thing, Fixture) else get_pytest_fixture_name(thing)
return cls(name)
@dataclass
class Literal(Base):
name: ...
def parameters(*parameters):
parameters_names = []
fixtures_names = []
for parameter in parameters:
if isinstance(parameter, str):
parameter = Literal(parameter)
if not isinstance(parameter, Literal):
parameter = Fixture.from_anything(parameter)
parameters_names.append(parameter.name)
if isinstance(parameter, Fixture):
fixtures_names.append(parameter.name)
def argumentize(*iterable_of_argument_sets):
def decorator(func):
if hasattr(func, "_pytestfixturefunction"):
argumentize_fixture(func, parameters_names, iterable_of_argument_sets)
return func
else:
return pytest.mark.parametrize(
parameters_names,
iterable_of_argument_sets,
indirect=fixtures_names
)(func)
decorator._metadata = SimpleNamespace(
parameters=parameters,
iterable_of_argument_sets=iterable_of_argument_sets)
return decorator
return SimpleNamespace(argumentize=argumentize)
def join_Arguments(left, left_marks, right, right_marks):
common_parameters = set(left.kwargs) & set(right.kwargs)
if common_parameters:
raise ValueError("Can't argumentize the same parameter more than once: "
+ ','.join(repr(parameter) for parameter in common_parameters))
result = Arguments(**left.kwargs, **right.kwargs)
result_marks = left_marks + right_marks
if result_marks:
result = pytest.param(result, marks=result_marks)
return result
def argumentize_fixture(func, parameters_names, argument_sets):
pytest_params = func._pytestfixturefunction.params
new_pytest_params = []
if not pytest_params:
# simplifies "multiplication" logic—
# no need to handle the value None in any special way
pytest_params = (Arguments(),)
for argument_set in argument_sets:
if isinstance(argument_set, ParameterSet):
argument_set, new_marks = argument_set.values, argument_set.marks
else:
new_marks = ()
if len(parameters_names) != len(argument_set):
raise ValueError("The number of parameters and the number of arguments must be the same")
# while fixture parameters can be positional,
# argumentization always works with named parameters.
new_arguments = Arguments(**dict(zip(parameters_names, argument_set)))
for old_arguments in pytest_params:
if isinstance(old_arguments, ParameterSet):
# since internally we are "parametrizing" the whole fixture and nothing else,
# old arguments will only hold a single value
old_arguments, old_marks = old_arguments.values[0], old_arguments.marks
else:
old_marks = ()
new_pytest_params.append(join_Arguments(
old_arguments, old_marks, new_arguments, new_marks))
func._pytestfixturefunction = attr.evolve(func._pytestfixturefunction,
params=new_pytest_params)
arguments = Arguments
fixture_taking_arguments.by_name = Fixture
fixture_taking_arguments.literal = Literal
from plugin import parameters, arguments
from plugin import fixture_taking_arguments as fixture
from experimental import create_sync
import pytest
@fixture
def dog(request, /, name, age=69):
return f"{name} the dog aged {age}"
@fixture
def owner(request, dog, /, name="John Doe"):
yield f"{name}, owner of {dog}"
class TestArgumentPassing:
@dog.arguments("Buddy", age=7)
def test_with_dog(self, dog):
assert dog == "Buddy the dog aged 7"
@dog.arguments("Champion")
class TestChampion:
def test_with_dog(self, dog):
assert dog == "Champion the dog aged 69"
def test_with_default_owner(self, owner, dog):
assert owner == "John Doe, owner of Champion the dog aged 69"
assert dog == "Champion the dog aged 69"
@owner.arguments("John Travolta")
def test_with_named_owner(self, owner):
assert owner == "John Travolta, owner of Champion the dog aged 69"
@fixture.by_name("dog").arguments("Buddy", age=7)
def test_with_dog_by_name(self, dog):
assert dog == "Buddy the dog aged 7"
class TestTestArgumentization:
@fixture.literal("axolotl").argumentize("big", "small")
def test_with_axolotl(self, axolotl):
assert axolotl in ["big", "small"]
@dog.argumentize(
"Charlie",
arguments("Buddy", age=7),
pytest.param("Bob", marks=pytest.mark.xfail))
def test_with_dog_argumentized(self, dog):
assert dog in [
"Charlie the dog aged 69",
"Buddy the dog aged 7",
# Bob is missing — xfail
]
@fixture
def cat(self, request, /, name, age=420):
return f"{name} the cat aged {age}"
@parameters(dog,
"dog_color",
fixture.by_name("cat"),
fixture.literal("cat_color")).argumentize(
pytest.param("Charlie", "black", "Tom", "red", marks=pytest.mark.xfail),
(arguments("Buddy", 7), "spotted", arguments("Mittens", age=1), "striped"))
@fixture.literal("relationship").argumentize(
"friends",
"enemies")
def test_with_dogs_and_cats(self, dog, dog_color, cat, cat_color, relationship):
assert f"{dog}, {dog_color}, is {relationship} with {cat}, {cat_color}" in [
"Charlie the dog aged 69, black, is friends with Tom the cat aged 420, red", # xpass
"Buddy the dog aged 7, spotted, is friends with Mittens the cat aged 1, striped",
"Charlie the dog aged 69, black, is enemies with Tom the cat aged 420, red", # xpass
"Buddy the dog aged 7, spotted, is enemies with Mittens the cat aged 1, striped",
]
class TestFixtureArgumentization:
@fixture.literal("name").argumentize(
"Veronica",
"Greta",
pytest.param("Margaret", marks=pytest.mark.xfail))
@fixture
def hedgehog(self, request, /, name):
return f"{name} the hedgehog"
def test_with_hedgehog(self, hedgehog):
assert hedgehog in [
"Veronica the hedgehog",
"Greta the hedgehog",
]
@fixture.literal("name").argumentize(
pytest.param("Bob", marks=pytest.mark.xfail),
"Liza",
"Eoin")
@fixture.literal("age").argumentize(
12,
pytest.param(-1, marks=pytest.mark.xfail),
13)
@fixture
def wombat(self, request, /, name, age):
return f"{name} the hedgehog aged {age}"
def test_with_wombat(self, wombat):
assert wombat in [
"Liza the hedgehog aged 12",
"Eoin the hedgehog aged 12",
"Liza the hedgehog aged 13",
"Eoin the hedgehog aged 13",
]
@parameters("word").argumentize(("boo",), ("meow",)) # shouldn't do this
@parameters("name", "age").argumentize(
("Liza", 17),
("Eoin", 39),
pytest.param("Eoin", 39, marks=pytest.mark.xfail), # xpass
pytest.param("Zoey", 7, marks=pytest.mark.xfail)) # xfail
@fixture
def panda(self, request, /, name, age, word):
return f"{name} the panda aged {age} says: {word}"
def test_with_panda(self, panda):
assert panda in [
"Liza the panda aged 17 says: boo",
"Eoin the panda aged 39 says: boo",
"Liza the panda aged 17 says: meow",
"Eoin the panda aged 39 says: meow",
]
# similar error should happen with test argumentization
# but it happens on collection time so can't test it this way
class TestErrors:
def test_wrong_number_of_arguments(self):
with pytest.raises(ValueError):
@parameters("one", "two").argumentize(("one", "two", "three"))
@fixture
def test_foo(one, two):
pass
def test_argumentize_same_parameter_twice(self):
with pytest.raises(ValueError):
@fixture.literal("one").argumentize("1", "one")
@fixture.literal("one").argumentize("один", "一")
@fixture
def test_foo(one, two):
pass
class TestExperimental:
@fixture
def raccoon(self, request, /):
return "Bob the raccoon"
@fixture
def eagle(self, request, /, name="Jake"):
return f"{name} the eagle"
@fixture.literal("name").argumentize("Matthew", "Bartholomew")
@fixture.literal("size").argumentize("big", "small")
@fixture
def sparrow(self, request, /, name, size):
return f"{name} the {size} sparrow"
animal = create_sync("animal").argumentize(
"Todd the toad",
raccoon,
eagle.arguments("William"),
eagle.argumentize("Luke", arguments("Simon")),
eagle,
sparrow,
)
animal = staticmethod(animal) # because we are in a class
def test_with_animal(self, animal):
assert animal in {
"Todd the toad",
"Bob the raccoon",
"William the eagle",
"Luke the eagle",
"Simon the eagle",
"Jake the eagle",
"Matthew the big sparrow",
"Matthew the small sparrow",
"Bartholomew the big sparrow",
"Bartholomew the small sparrow",
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment