Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions graph/python/engine/config-resolution/knobs.dl
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,35 @@ py_task_dispatch_method("apply_async").
// the fixture to produce the argument. The parameter name is the only join.
py_fixture_deco("fixture").

// ── py_fixture_name_kw(Keyword): the decorator argument that names the fixture ─
// `@pytest.fixture(name="client") def client_fixture()` registers `client`, and a test
// requesting `client_fixture` gets nothing. The documented way to keep a fixture from
// shadowing a module-level name.
py_fixture_name_kw("name").

// ── py_fixture_plugins_var(Name): the module global that lists plugin modules ─
// `pytest_plugins = ["pkg.fixtures"]` (a string, a list or a tuple) makes the runner
// import those modules as plugins, and a plugin's fixtures are visible to every test in
// the session, not only to the directory of the module that listed them.
py_fixture_plugins_var("pytest_plugins").

// ── py_parametrize_deco(Tail) and its keywords: a value supplied DIRECTLY ────
// `@pytest.mark.parametrize("user", [...])` passes the value in, and the fixture called
// `user` is NOT run for that test. With `indirect=True`, or with the name in an
// `indirect=[...]` list, the value goes to the fixture instead and the fixture runs.
py_parametrize_deco("parametrize").
py_parametrize_argnames_kw("argnames").
py_parametrize_indirect_kw("indirect").
py_parametrize_indirect_all("True").

// ── py_argnames_sep / py_argnames_lead: how "a, b" separates argument names ──
// The runner splits the string on "," and strips each piece. Souffle cannot split, so a
// name is tested as a whole token: led by a separator (with or without the usual single
// space) and followed by one, inside the string wrapped in separators.
py_argnames_sep(",").
py_argnames_lead(",").
py_argnames_lead(", ").

// ── py_test_name_prefix(Prefix) — how a runner recognises a test ─────────────
// Injection happens only into a COLLECTED test. Without this, every function whose
// parameter happens to share a fixture's name would be wired to it.
Expand Down
121 changes: 111 additions & 10 deletions graph/python/engine/framework-behavior/dispatch.dl
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
.decl py_fw_deco_tail(v:symbol)
py_fw_deco_tail(v) :- py_task_register_deco(v).
py_fw_deco_tail(v) :- py_fixture_deco(v).
py_fw_deco_tail(v) :- py_parametrize_deco(v).
py_fw_deco_tail(v) :- py_signal_receiver_deco(v).
py_fw_deco_tail(v) :- py_orm_hook_deco(v).

Expand Down Expand Up @@ -146,9 +147,20 @@ framework_edge(from, to, "task_dispatch", meth, "registered") :-
// The runner matches a test's PARAMETER NAME against the declared fixtures and calls
// the fixture to build the argument. The name is the entire join, so the confidence is
// `by_name`: it is correct exactly where the runner's scoping agrees with lexical scope.
//
// THE NAME IS THE ONE THE DECORATOR REGISTERS. `@fixture(name="client")` on
// `def client_fixture()` registers `client`, and only a parameter called `client` gets
// it. Keying on the def name wires the wrong parameter and misses the right one.
.decl py_fixture_decl(m:symbol, name:symbol)
py_fixture_decl(m, name) :- decorator_target("client", _, m, h), m != "",
py_deco_named(h, v), py_fixture_deco(v),
.decl py_fixture_deco_on(m:symbol, h:symbol)
py_fixture_deco_on(m, h) :- decorator_target("client", _, m, h), m != "",
py_deco_named(h, v), py_fixture_deco(v).
.decl py_fixture_renamed(h:symbol, name:symbol)
py_fixture_renamed(h, name) :- py_fixture_deco_on(_, h),
annotation_arg("client", k, name, "STRING_LITERAL", _, "true", h, _),
py_fixture_name_kw(k), name != "".
py_fixture_decl(m, name) :- py_fixture_deco_on(m, h), py_fixture_renamed(h, name).
py_fixture_decl(m, name) :- py_fixture_deco_on(m, h), !py_fixture_renamed(h, _),
method_decl("client", name, _, _, _, m).

// The runner calls a requested fixture; nothing in the client does.
Expand Down Expand Up @@ -191,42 +203,131 @@ py_fixture_requester(m) :- py_fixture_decl(m, _).
// strict > excludes it and the commonest place a project keeps shared fixtures
// contributes nothing at all. At the root d is "" and the prefix test admits every
// requester, which is exactly right.
//
// A FIXTURE BELONGS TO EVERY MODULE IT IS AN ATTRIBUTE OF, not only to the file that
// declares it. The runner registers the fixtures it finds among a module's attributes,
// so `from .fixtures import *` in a conftest, or `from .helpers import helper_fx` in a
// test module, makes the fixture that module's own. Keyed on the declaring file alone,
// a fixture module a conftest star-imports reached no test at all (#1537).
// module_member_method already applies the language's export rule to a star import
// (`__all__`, else no leading underscore), so a name the star does not bind is not
// registered here either.
.decl py_fixture_in_file(fix:symbol, file:symbol)
py_fixture_in_file(fix, p) :- py_fixture_decl(fix, _), method_file("client", p, fix).
py_fixture_in_file(fix, p) :- py_fixture_decl(fix, _),
module_member_method("client", mod, _, fix), module_file("client", p, mod).

.decl py_fixture_conftest(fix:symbol, dir:symbol)
py_fixture_conftest(fix, d) :- py_fixture_decl(fix, _),
method_file("client", p, fix), py_fixture_scope_file(c),
py_fixture_conftest(fix, d) :- py_fixture_in_file(fix, p), py_fixture_scope_file(c),
strlen(p) >= strlen(c), substr(p, strlen(p) - strlen(c), strlen(c)) = c,
d = substr(p, 0, strlen(p) - strlen(c)).

// py_fixture_plugin(Fixture) -- a fixture of a module some module names in its
// `pytest_plugins` global. A plugin is registered for the whole session, so its fixtures
// are visible to every test, below any conftest or same-module fixture of the name.
// The value is a string, or a list or tuple of strings, each a dotted module name,
// joined to the module by that exact qualified name.
.decl py_fixture_plugin_name(q:symbol)
py_fixture_plugin_name(q) :- py_fixture_plugins_var(n), py_module_level_binding(_, n, b),
expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, val),
expr_node("client", "LITERAL", _, q, val), q != "".
py_fixture_plugin_name(q) :- py_fixture_plugins_var(n), py_module_level_binding(_, n, b),
expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, val),
expr_parent("client", val, "ELEMENT", _, el),
expr_node("client", "LITERAL", _, q, el), q != "".
.decl py_fixture_plugin(fix:symbol)
py_fixture_plugin(fix) :- py_fixture_plugin_name(q), module_decl("client", _, q, _, mod),
module_file("client", p, mod), py_fixture_in_file(fix, p).

// A requested parameter: no default, not a receiver, on a test or another fixture.
.decl py_fixture_request(req:symbol, name:symbol)
py_fixture_request(req, name) :- py_fixture_requester(req),
// And NOT an argument the test's own parametrize decorator supplies DIRECTLY: the runner
// passes that value in and never runs the fixture of the name (#1529). An `indirect`
// one is handed to the fixture, which does run, so it stays a request.
.decl py_fixture_param(req:symbol, name:symbol)
py_fixture_param(req, name) :- py_fixture_requester(req),
param_decl("client", name, _, _, req, ph), name != "",
param_default("client", "false", _, ph),
param_kind("client", _, _, "false", ph).

// (a) the test's own module wins outright.
.decl py_parametrize_on(req:symbol, h:symbol)
py_parametrize_on(req, h) :- decorator_target("client", _, req, h), req != "",
py_deco_named(h, v), py_parametrize_deco(v).
// the argnames, as the string "a, b" (first positional, or the keyword) ...
.decl py_parametrize_argnames(h:symbol, s:symbol)
py_parametrize_argnames(h, s) :- py_parametrize_on(_, h),
annotation_arg("client", _, s, "STRING_LITERAL", "0", "false", h, _).
py_parametrize_argnames(h, s) :- py_parametrize_on(_, h),
annotation_arg("client", k, s, "STRING_LITERAL", _, "true", h, _),
py_parametrize_argnames_kw(k).
.decl py_parametrize_argname(req:symbol, h:symbol, name:symbol)
py_parametrize_argname(req, h, name) :- py_fixture_param(req, name),
py_parametrize_on(req, h), py_parametrize_argnames(h, s),
py_argnames_sep(sep), py_argnames_lead(lead),
contains(cat(lead, cat(name, sep)), cat(sep, cat(s, sep))).
// ... or as a list or tuple of strings.
py_parametrize_argname(req, h, name) :- py_fixture_param(req, name),
py_parametrize_on(req, h), decorator_expr("client", de, h), call_arg(de, "0", a),
expr_parent("client", a, "ELEMENT", _, el),
expr_node("client", "LITERAL", _, name, el).
.decl py_parametrize_indirect(h:symbol, name:symbol)
py_parametrize_indirect(h, name) :- py_parametrize_argname(_, h, name),
annotation_arg("client", k, v, _, _, "true", h, _),
py_parametrize_indirect_kw(k), py_parametrize_indirect_all(v).
py_parametrize_indirect(h, name) :- py_parametrize_argname(_, h, name),
decorator_expr("client", de, h), call_kwarg(de, k, a), py_parametrize_indirect_kw(k),
expr_parent("client", a, "ELEMENT", _, el),
expr_node("client", "LITERAL", _, name, el).
.decl py_parametrize_direct(req:symbol, name:symbol)
py_parametrize_direct(req, name) :- py_parametrize_argname(req, h, name),
!py_parametrize_indirect(h, name).

.decl py_fixture_request(req:symbol, name:symbol)
py_fixture_request(req, name) :- py_fixture_param(req, name),
!py_parametrize_direct(req, name).

// (0) a fixture a CLASS declares serves the tests of that class and of its subclasses,
// and nothing else; for them it wins over the module's fixture of the name. Read as a
// plain same-module fixture it was also handed to a sibling class's tests, and the
// module fixture it overrides was handed to its own (#1512).
.decl py_fixture_class(fix:symbol, t:symbol)
py_fixture_class(fix, t) :- py_fixture_decl(fix, _), method_owner("client", t, fix), t != "".
.decl py_fixture_in_class(req:symbol, name:symbol, fix:symbol)
py_fixture_in_class(req, name, fix) :- py_fixture_request(req, name),
py_fixture_decl(fix, name), fix != req, py_fixture_class(fix, t),
method_owner("client", u, req), type_self_or_ancestor("client", u, t).
.decl py_fixture_class_shadowed(req:symbol, name:symbol)
py_fixture_class_shadowed(req, name) :- py_fixture_in_class(req, name, _).

// (a) the test's own module wins next: declared at its top level, or imported into it.
.decl py_fixture_same_module(req:symbol, name:symbol, fix:symbol)
py_fixture_same_module(req, name, fix) :- py_fixture_request(req, name),
py_fixture_decl(fix, name), fix != req,
method_file("client", p, req), method_file("client", p, fix).
py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _),
!py_fixture_class_shadowed(req, name),
method_file("client", p, req), py_fixture_in_file(fix, p).
.decl py_fixture_shadowed(req:symbol, name:symbol)
py_fixture_shadowed(req, name) :- py_fixture_same_module(req, name, _).
py_fixture_shadowed(req, name) :- py_fixture_class_shadowed(req, name).

// (b) otherwise a conftest at or above the requester, NEAREST first. Depth is the
// length of the governed directory, so the longest prefix is the nearest ancestor.
.decl py_fixture_conftest_cand(req:symbol, name:symbol, fix:symbol, depth:number)
py_fixture_conftest_cand(req, name, fix, dl) :- py_fixture_request(req, name),
py_fixture_decl(fix, name), fix != req,
py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _),
py_fixture_conftest(fix, d), method_file("client", p, req),
strlen(p) > strlen(d), substr(p, 0, strlen(d)) = d, dl = strlen(d).
.decl py_fixture_nearest(req:symbol, name:symbol, depth:number)
py_fixture_nearest(req, name, m) :- py_fixture_conftest_cand(req, name, _, _),
m = max dl : { py_fixture_conftest_cand(req, name, _, dl) }.

.decl py_fixture_injection(from:symbol, to:symbol, name:symbol)
py_fixture_injection(req, fix, name) :- py_fixture_in_class(req, name, fix).
py_fixture_injection(req, fix, name) :- py_fixture_same_module(req, name, fix).
py_fixture_injection(req, fix, name) :- py_fixture_conftest_cand(req, name, fix, dl),
!py_fixture_shadowed(req, name), py_fixture_nearest(req, name, dl).
// (c) failing both, a plugin's fixture, which every test sees.
py_fixture_injection(req, fix, name) :- py_fixture_request(req, name),
py_fixture_decl(fix, name), fix != req, py_fixture_plugin(fix), !py_fixture_class(fix, _),
!py_fixture_shadowed(req, name), !py_fixture_nearest(req, name, _).

.decl py_fixture_requested(m:symbol)
py_fixture_requested(m) :- py_fixture_injection(_, m, _).
Expand Down
8 changes: 8 additions & 0 deletions graph/python/souffle/decls_all.dl
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@
.decl py_fixture_deco(v:symbol)
.decl py_fixture_scope_file(v:symbol)
.decl py_test_name_prefix(v:symbol)
.decl py_fixture_name_kw(v:symbol)
.decl py_fixture_plugins_var(v:symbol)
.decl py_parametrize_deco(v:symbol)
.decl py_parametrize_argnames_kw(v:symbol)
.decl py_parametrize_indirect_kw(v:symbol)
.decl py_parametrize_indirect_all(v:symbol)
.decl py_argnames_sep(v:symbol)
.decl py_argnames_lead(v:symbol)
.decl py_route_table(v:symbol)
.decl py_route_entry_fn(v:symbol)
.decl py_signal_receiver_deco(v:symbol)
Expand Down
Empty file.
46 changes: 46 additions & 0 deletions graph/test/python/cases/30-fixture-visibility/src/app/svc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Production code. Each function is reached from a test ONLY through one fixture body,
so which fixture a test is wired to decides which of these it reaches."""


def make_repo():
return "repo"


def make_service():
return "service"


def make_cache():
return "cache"


def make_client():
return "client"


def make_user(name):
return {"name": name}


def make_widget():
return "widget"


def make_orphan():
return "orphan"


def make_helper():
return "helper"


def make_hidden():
return "hidden"


def module_account():
return "module"


def class_account():
return "class"
2 changes: 2 additions & 0 deletions graph/test/python/cases/30-fixture-visibility/src/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""The root conftest, and the one place a `pytest_plugins` list is honoured."""
pytest_plugins = ["testsupport.plugin"]
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""A sibling directory's conftest, star-importing its own fixture module by its
ABSOLUTE name (tests/conftest.py uses the relative spelling)."""
from other.more_fixtures import *
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import pytest

from app.svc import make_widget


@pytest.fixture
def widget():
return make_widget()
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
def test_widget(widget):
"""other/more_fixtures.py::widget, through this directory's conftest."""
assert widget


def test_service_here(service):
"""testsupport/plugin.py::service: a plugin fixture is visible outside tests/ too."""
assert service
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Four ways a fixture reaches this directory's tests, and one way it does not.

cache declared here
repo star-imported from .fixtures
client registered under the name given to the decorator; the def is
`client_fixture`, and a test asking for `client_fixture` gets nothing
user requested three ways by tests/test_params.py, one of which shadows it
"""
import pytest

from app.svc import make_cache, make_client, make_user

from .fixtures import *


@pytest.fixture
def cache():
return make_cache()


@pytest.fixture(name="client")
def client_fixture():
return make_client()


@pytest.fixture
def user():
return make_user("default")
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Fixtures that reach tests only because tests/conftest.py star-imports this module.

The runner registers every public attribute of a conftest, and `from .fixtures import *`
makes `repo` one. `_hidden` is not re-exported by a star import (a leading underscore),
so it is not a conftest attribute and no test may reach it through the star.
"""
import pytest

from app.svc import make_hidden, make_repo


@pytest.fixture
def repo():
return make_repo()


@pytest.fixture
def _hidden():
return make_hidden()
10 changes: 10 additions & 0 deletions graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Imported by NAME into one test module, which makes `helper_fx` an attribute of that
module and so a fixture the runner finds there, and only there."""
import pytest

from app.svc import make_helper


@pytest.fixture
def helper_fx():
return make_helper()
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""A fixture a class declares serves that class and its subclasses, and overrides the module's."""
import pytest

from app.svc import class_account, module_account


@pytest.fixture
def account():
return module_account()


class TestOverride:
@pytest.fixture
def account(self):
return class_account()

def test_in_class(self, account):
"""TestOverride.account only: the class fixture shadows the module one."""
assert account == "class"


class TestOverrideChild(TestOverride):
def test_in_subclass(self, account):
"""TestOverride.account, inherited."""
assert account == "class"


class TestPlain:
def test_no_override(self, account):
"""the module fixture: a SIBLING class's fixture is not visible here."""
assert account == "module"


def test_module_level(account):
"""the module fixture."""
assert account == "module"
Loading
Loading