Skip to content

How to mark up fixtures with metadata for segregating collections of fixtures in scope #11462

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
jxramos opened this issue Sep 23, 2023 · 11 comments
Labels
topic: fixtures anything involving fixtures directly or indirectly topic: marks related to marks, either the general marks or builtin type: question general question, might be closed after 2 weeks of inactivity

Comments

@jxramos
Copy link

jxramos commented Sep 23, 2023

What's the problem this feature will solve?

The problem to be solved here is how to organize families of fixtures with pure metadata to avoid looking up fixture values from their names to determine if the fixture belongs to a given family or not.

I'm looking for a way that I can mark up a bunch of fixtures spread around a bunch of plugin and conftest files etc some of which are marked up with the simple standard markers pytest.mark.metaFoo. I wish to then lookup those marks from the FixtureDef instances retained in the fixture manager to do some dynamic container creations from these fixture families identified by their markers alone. I wish to do this family identification via markers because fixture naming convention alone is inadequate for my use case where looking up fixture values for those fixtures which match a naming convention can cause recursion loops for those fixtures that hold dependencies on the fixture I'm trying to dynamically populate with the fixtures of a given family but not others. This is where marker metadata would be a powerful differentiator.

Describe the solution you'd like

I'd like to be able to decorate my fixtures with regular pytest markers as such (which is valid syntax today) but with that marker metadata able to be crawled and looked up from the request fixture object and ideally populated in the FixtureDef instances.

# plugin_1.py
import pytest
@pytest.mark.fixA
@pytest.mark.other
@pytest.fixture
def fixture_foo():
    return "foo"

@pytest.mark.fixB
@pytest.fixture
def fixture_bar():
    return "bar"


# plugin_2.py
import pytest

@pytest.mark.fixA
@pytest.fixture
def fixture_2foo():
    return "2foo"

@pytest.mark.fixB
@pytest.fixture
def fixture_2bar():
    return "2bar"

From the above I need a fixture that dynamically returns a container of the fixture values ["foo", "2foo"] just by discovering the fixtures in scope and contributing to that container the values of all fixtures which match the family criteria corresponding to the pytest markers I wish to filter upon.

Alternative Solutions

So far I've been leaning on a combination of fixture naming conventions and value lookups to get this organization by families accomplished but that will not work when I get to the complex case where the different fixture subfamilies share the same naming convention and thus become undifferentiable. In that scenario the fixtures are given different definitions from alternate plugins where I don't want them to continue to be selected by name anymore because the naming convention then blurs between two families of fixtures that have no way to be differentiated by name alone (hence the need for the markers to do the job).

Here's the partial solution I've attempted so far that lets me organize fixtures by the naming convention pattern matching a suffix

@pytest.fixture(autouse=True)
def fix_list(request):
    print("fix_list")
    foo_fixtures = [request.getfixturevalue(fix_name) for fix_name in request._fixturemanager._arg2fixturedefs if fix_name.endswith("_foo")]
    return foo_fixtures

https://stackoverflow.com/a/77154959/1330381

Additional context

When I probe the fixture definition from a global scope I see that the fixtures have a pytestmark attribute, eg

fixture_foo.pytestmark
[Mark(name='fixA', args=(), kwargs={}), Mark(name='other', args=(), kwargs={})]

I can't see how to navigate to that data from the request fixture. If there is an existing means today that would solve all my problems and I could mark up my fixtures today and solve some gnarly problems.

@jxramos
Copy link
Author

jxramos commented Sep 25, 2023

Taking a look at the callstack for where FixtureDef instances get constructed, it appears to be located here for my user defined fixtures (the line numbers may be off on account of my pdb.set_trace() inserts and other debug prints)

pytest/src/_pytest/fixtures.py

Lines 1698 to 1708 in 1a16bac

fixture_def = FixtureDef(
fixturemanager=self,
baseid=nodeid,
argname=name,
func=obj,
scope=marker.scope,
params=marker.params,
unittest=unittest,
ids=marker.ids,
_ispytest=True,
)

I'll see if snooping around any of these objects at this point in the parsefactories method can make the markers visible for the fixtures. If so that will make things in reach for adding a markers attribute for the Fixture Definition class if that is agreeable.

(Pdb) w
  /usr/lib/python3.8/runpy.py(194)_run_module_as_main()
-> return _run_code(code, main_globals, None,
  /usr/lib/python3.8/runpy.py(87)_run_code()
-> exec(code, run_globals)
  /home/USERX/.local/lib/python3.8/site-packages/pytest/__main__.py(5)<module>()
-> raise SystemExit(pytest.console_main())
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/config/__init__.py(188)console_main()
-> code = main()
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/config/__init__.py(165)main()
-> ret: Union[ExitCode, int] = config.hook.pytest_cmdline_main(
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_hooks.py(433)__call__()
-> return self._hookexec(self.name, self._hookimpls, kwargs, firstresult)
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_manager.py(112)_hookexec()
-> return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_callers.py(80)_multicall()
-> res = hook_impl.function(*args)
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/main.py(315)pytest_cmdline_main()
-> return wrap_session(config, _main)
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/main.py(268)wrap_session()
-> session.exitstatus = doit(config, session) or 0
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/main.py(321)_main()
-> config.hook.pytest_collection(session=session)
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_hooks.py(433)__call__()
-> return self._hookexec(self.name, self._hookimpls, kwargs, firstresult)
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_manager.py(112)_hookexec()
-> return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_callers.py(80)_multicall()
-> res = hook_impl.function(*args)
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/main.py(332)pytest_collection()
-> session.perform_collect()
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/main.py(657)perform_collect()
-> self.items.extend(self.genitems(node))
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/main.py(825)genitems()
-> rep = collect_one_node(node)
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/runner.py(544)collect_one_node()
-> rep: CollectReport = ihook.pytest_make_collect_report(collector=collector)
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_hooks.py(433)__call__()
-> return self._hookexec(self.name, self._hookimpls, kwargs, firstresult)
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_manager.py(112)_hookexec()
-> return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  /home/USERX/.local/lib/python3.8/site-packages/pluggy/_callers.py(80)_multicall()
-> res = hook_impl.function(*args)
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/runner.py(371)pytest_make_collect_report()
-> call = CallInfo.from_call(lambda: list(collector.collect()), "collect")
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/runner.py(340)from_call()
-> result: Optional[TResult] = func()
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/runner.py(371)<lambda>()
-> call = CallInfo.from_call(lambda: list(collector.collect()), "collect")
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/python.py(515)collect()
-> self.session._fixturemanager.parsefactories(self)
  /home/USERX/.local/lib/python3.8/site-packages/_pytest/fixtures.py(1649)parsefactories()
-> fixture_def = FixtureDef(
> /home/USERX/.local/lib/python3.8/site-packages/_pytest/fixtures.py(992)__init__()
-> self._fixturemanager = fixturemanager

@jxramos
Copy link
Author

jxramos commented Sep 25, 2023

Aha, just found it, so obj still has the pytestmark list associated with it BUT once it gets transformed through obj = get_real_method(obj, holderobj) the obj no longer has its markers as you can see in the dir output printed below

> /home/USERX/.local/lib/python3.8/site-packages/_pytest/fixtures.py(1647)parsefactories()
-> obj = get_real_method(obj, holderobj)
(Pdb) obj
<function fixture2_foo at 0x7fb78c74aee0>
(Pdb) dir(obj)
[... '__pytest_wrapped__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__wrapped__', '_pytestfixturefunction', 'pytestmark']
(Pdb) n
> /home/USERX/.local/lib/python3.8/site-packages/_pytest/fixtures.py(1649)parsefactories()
-> fixture_def = FixtureDef(
(Pdb) dir(obj)
[... '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

presumably I could extract the markers list before the call to get_real_method and cache it in the FixtureDef with a new parameter? There could be some edge cases to consider with parametrization and other things I'm not thinking of that could collide. I'll need some help here getting oriented to this all and learning from any foresight folks could share from the dev team.

@jxramos
Copy link
Author

jxramos commented Sep 25, 2023

I went ahead and printed any fixture which doesn't have pytestmark, so the challenge will be how to conditionally include this marker metadata without disrupting the other fixtures which should be oblivious to this world.

NoPytestmark anyio_backend
NoPytestmark anyio_backend_name
NoPytestmark anyio_backend_options
NoPytestmark cache
NoPytestmark capfd
NoPytestmark capfdbinary
NoPytestmark caplog
NoPytestmark capsys
NoPytestmark capsysbinary
NoPytestmark check
NoPytestmark doctest_namespace
NoPytestmark fixture_no_marks <------------ my user defined fixture I created with no pytest markers
NoPytestmark include_metadata_in_junit_xml
NoPytestmark json_metadata
NoPytestmark metadata
NoPytestmark monkeypatch
NoPytestmark pytestconfig
NoPytestmark record_property
NoPytestmark record_testsuite_property
NoPytestmark record_xml_attribute
NoPytestmark recwarn
NoPytestmark tmp_path
NoPytestmark tmp_path_factory
NoPytestmark tmpdir
NoPytestmark tmpdir_factory

Perhaps one approach would be to default to the empty list if the pytestmark attribute is missing.

            fixture_makers = obj.pytestmark if hasattr(obj,"pytestmark") else []
            obj = get_real_method(obj, holderobj)
            ...
            fixture_def = FixtureDef(
                fixturemanager=self,
                baseid=nodeid,
                argname=name,
                func=obj,
                scope=marker.scope,
                params=marker.params,
                markers = fixture_markers,
                unittest=unittest,
                ids=marker.ids,
            )

perhaps there's a better way to map this information that could be looked up in an alternate structure beside the fixture manager where I could take the names of the fixtures present in the keys of the fixture manager and just lookup in this alternate datasource the fixtures and their markers, fixtures_2_markes = {"fixture_name": [<PytestMarker>]}. Then I could just do a straight dict lookup like get_fixture_markes = fixtures_2_markers.get("fixture_name", [])

@RonnyPfannschmidt
Copy link
Member

Markers intentionally do not work on fixtures and do not transfer

Its not correctly manageable in quite a number of edge cases surrounding fixtures, fixture overrides and scoping/Parameterization

@jxramos
Copy link
Author

jxramos commented Sep 25, 2023

can you elaborate on the terminology of 'work on' and 'do not transfer' here please.

If I'm not mistaken I recall a really good rich conversation over in #1368 that I was keen to tune in on that brought up this transferring terminology in the context of test cases that used a particular marked fixture. If I recall correctly that notion of transferring was the concept of taking markers from fixtures and transitively applying them to any test case which happened to use the marked up fixtures. That's not quite the use case I'm wrestling with however.

I want the markers on the fixtures not to produce a convenience marker test case filtering scheme but form a basis for organizing and filtering the fixtures themselves. My focus isn't about the test cases which may or may not use those fixtures I hope to organize but basically I want metadata to be used to produce a transcendent derivative fixture from other fixtures that are already in scope. In a nutshell Fixtures created by other fixtures.

Maybe I can get some insight into the commit that added get_real_method, which appears to be work done by @nicoddemus over in
c6b11b9#diff-b24b16546a68963d52dedaa79e73881adebdf116c4fdfc3859658879d13b9321

There's a curious comment over there...

This avoids having to unwrap it later and lose attached information such as "async" functions.

for sure the pytestmark attribute gets lost passing through that method as I found in my comment above. This commit also links to another interesting comment in the issue it references

since pytest core doesn't support markers on fixtures to begin with this is a bit of a grey area,
#3747 (comment)

would you say markers on fixtures is still a grey area or something more rejected at this later date from the time of those above exchanges?

Maybe another syntax that could possibly accomplish this organizational attribute I wish to get access to is to use custom kwargs within the fixtures, such as...

@pytest.fixture(scope="function, meta_id="foo")

Is that syntax that I could tie into somehow and fetch from request objects to do my fixture organization with?

@jxramos
Copy link
Author

jxramos commented Sep 25, 2023

looks like there's no kwarg support around the decorator, the lone * is to perhaps handle the test parameter values?

pytest/src/_pytest/fixtures.py

Lines 1259 to 1269 in 1a16bac

def fixture( # noqa: F811
fixture_function: Optional[FixtureFunction] = None,
*,
scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = "function",
params: Optional[Iterable[object]] = None,
autouse: bool = False,
ids: Optional[
Union[Sequence[Optional[object]], Callable[[Any], Optional[object]]]
] = None,
name: Optional[str] = None,
) -> Union[FixtureFunctionMarker, FixtureFunction]:

Snooping around that class FixtureFunctionMarker leads to this curious line

pytest/src/_pytest/fixtures.py

Lines 1209 to 1210 in 1a16bac

if hasattr(function, "pytestmark"):
warnings.warn(MARKED_FIXTURE, stacklevel=2)

at which point I can see the connection to deprecated code

MARKED_FIXTURE = PytestRemovedIn8Warning(
"Marks applied to fixtures have no effect\n"
"See docs: https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function"
)

but interestingly enough that link doesn't have any hits to this subject any longer, maybe it did in the past? This is a bit surprising https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function

@RonnyPfannschmidt
Copy link
Member

It was never support,but people still wrote code as if it was

@jxramos
Copy link
Author

jxramos commented Sep 25, 2023

Another angle I may be able to map against is the baseid / nodeid values if they can connect me to the path to where the fixture was found. I can alternatively embed a keyword in the plugin's filename but I'm finding the empty string being returned for these parameters.

This may be by design perhaps for reasons unclear to me For other plugins, the baseid is the empty string (always matches). what is the matching constraint being discussed here?

# The "base" node ID for the fixture.
#
# This is a node ID prefix. A fixture is only available to a node (e.g.
# a `Function` item) if the fixture's baseid is a parent of the node's
# nodeid (see the `iterparentnodeids` function for what constitutes a
# "parent" and a "prefix" in this context).
#
# For a fixture found in a Collector's object (e.g. a `Module`s module,
# a `Class`'s class), the baseid is the Collector's nodeid.
#
# For a fixture found in a conftest plugin, the baseid is the conftest's
# directory path relative to the rootdir.
#
# For other plugins, the baseid is the empty string (always matches).

@jxramos
Copy link
Author

jxramos commented Sep 26, 2023

come to think of it more generally, baseids, markers, whatever the source, I just need some metadata orthogonal to the fixture name which I can form associations with.

@Zac-HD Zac-HD added topic: fixtures anything involving fixtures directly or indirectly topic: marks related to marks, either the general marks or builtin type: question general question, might be closed after 2 weeks of inactivity labels Oct 1, 2023
@jxramos
Copy link
Author

jxramos commented Oct 3, 2023

Looks like with FixtureDef instances you can actually get the path via the inspect module on its func member which is pretty neat.

import inspect

@pytest.fixture
def fix_list(request):
    matching_fixtures = []
    for fixture_name, fixture_def in request._fixturemanager._arg2fixturedefs.items():
        if inspect.getabsfile(fixture_def.func).endswith("_my_naming_convention.py"):
            matching_fixtures.append(request.getfixturevalue(fixture_name))
    yield matching_fixtures

@Zac-HD
Copy link
Member

Zac-HD commented Feb 17, 2024

I think we never intended such code to work, and may deliberately break it in future. Probably the best approach to identifying families of fixtures is to track your metadata in some way unrelated to the pytest internals!

@Zac-HD Zac-HD closed this as completed Feb 17, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: fixtures anything involving fixtures directly or indirectly topic: marks related to marks, either the general marks or builtin type: question general question, might be closed after 2 weeks of inactivity
Projects
None yet
Development

No branches or pull requests

3 participants