Last active
January 20, 2021 22:37
-
-
Save oakkitten/f254af53d42344d72d78853543bf7d13 to your computer and use it in GitHub Desktop.
A bit crazy parameter passing with pytest
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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