Skip to content

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

Closed

Description

@jxramos

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.

Activity

  1. jxramos commented on Sep 25, 2023

    @jxramos
    ContributorAuthor

    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
    
  2. jxramos commented on Sep 25, 2023

    @jxramos
    ContributorAuthor

    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.

  3. jxramos commented on Sep 25, 2023

    @jxramos
    ContributorAuthor

    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", [])

  4. RonnyPfannschmidt commented on Sep 25, 2023

    @RonnyPfannschmidt
    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

  5. jxramos commented on Sep 25, 2023

    @jxramos
    ContributorAuthor

    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?

  6. jxramos commented on Sep 25, 2023

    @jxramos
    ContributorAuthor

    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

  7. RonnyPfannschmidt commented on Sep 25, 2023

    @RonnyPfannschmidt
    Member

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

  8. jxramos commented on Sep 25, 2023

    @jxramos
    ContributorAuthor

    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).

  9. jxramos commented on Sep 26, 2023

    @jxramos
    ContributorAuthor

    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.

  10. added
    topic: fixturesanything involving fixtures directly or indirectly
    topic: marksrelated to marks, either the general marks or builtin
    type: questiongeneral question, might be closed after 2 weeks of inactivity
    on Oct 1, 2023
  11. jxramos commented on Oct 3, 2023

    @jxramos
    ContributorAuthor

    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
  12. Zac-HD commented on Feb 17, 2024

    @Zac-HD
    Member

    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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    topic: fixturesanything involving fixtures directly or indirectlytopic: marksrelated to marks, either the general marks or builtintype: questiongeneral question, might be closed after 2 weeks of inactivity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions