diff --git a/gazelle/docs/installation_and_usage.md b/gazelle/docs/installation_and_usage.md index 3a8733cda8..b966641202 100644 --- a/gazelle/docs/installation_and_usage.md +++ b/gazelle/docs/installation_and_usage.md @@ -206,6 +206,28 @@ You can control the naming convention for test targets using the {term}`# gazelle:python_test_naming_convention value` directive. +#### Pytest plugins + +Gazelle resolves module-level `pytest_plugins` declarations into dependencies in +tests, root conftests, and reusable plugin modules: + +```python +pytest_plugins = ["myapp.testing.database", "myapp.testing.http"] +``` + +Literal strings, lists, and tuples are supported, including annotated assignments +and pytest's comma-separated string form. Existing resolution directives and +dependency-ignore annotations apply as they do to normal imports. + +Conditional declarations are included conservatively. Function/class-local +declarations are ignored. Literal entries in mixed lists are resolved, but dynamic +values, bytes, f-strings, and named Unicode escapes are not evaluated; use +{ref}`annotation-include-dep` for their dependencies. This does not change pytest's +invocation-wide plugin registration or its restriction on non-root conftests. + +:::{versionadded} VERSION_NEXT_FEATURE +::: + ### Binaries When a `__main__.py` file is encountered, this indicates the entry point diff --git a/gazelle/python/file_parser.go b/gazelle/python/file_parser.go index e129337e11..ed09eb5887 100644 --- a/gazelle/python/file_parser.go +++ b/gazelle/python/file_parser.go @@ -20,8 +20,10 @@ import ( "log" "os" "path/filepath" + "strconv" "strings" + bzl "github.com/bazelbuild/buildtools/build" sitter "github.com/smacker/go-tree-sitter" "github.com/smacker/go-tree-sitter/python" ) @@ -47,10 +49,11 @@ type ParserOutput struct { } type FileParser struct { - code []byte - relFilepath string - output ParserOutput - inTypeCheckingBlock bool + code []byte + relFilepath string + output ParserOutput + inTypeCheckingBlock bool + inLocalScope bool } func NewFileParser() *FileParser { @@ -241,6 +244,165 @@ func (p *FileParser) isTypeCheckingBlock(node *sitter.Node) bool { return false } +// pytestPluginExpression unwraps parentheses, ignoring comments before the value. +func pytestPluginExpression(node *sitter.Node) *sitter.Node { + for node != nil && node.Type() == "parenthesized_expression" { + var expression *sitter.Node + for i := 0; i < int(node.NamedChildCount()); i++ { + child := node.NamedChild(i) + if child.Type() != sitterNodeTypeComment { + expression = child + break + } + } + node = expression + } + return node +} + +// pytestPluginString evaluates literal Python strings without executing Python. +// Dynamic expressions, bytes, f-strings, and named Unicode escapes are not evaluated. +func pytestPluginString(node *sitter.Node, code []byte) (string, bool) { + node = pytestPluginExpression(node) + if node == nil { + return "", false + } + if node.Type() == "concatenated_string" { + var value strings.Builder + for i := 0; i < int(node.NamedChildCount()); i++ { + child := node.NamedChild(i) + if child.Type() == sitterNodeTypeComment { + continue + } + part, ok := pytestPluginString(child, code) + if !ok { + return "", false + } + value.WriteString(part) + } + return value.String(), true + } + if node.Type() != sitterNodeTypeString || node.HasError() { + return "", false + } + literal := node.Content(code) + quote := strings.IndexAny(literal, "\"'") + if quote < 0 { + return "", false + } + prefix := strings.ToLower(literal[:quote]) + if prefix != "" && prefix != "r" && prefix != "u" { + return "", false + } + // Python normalizes source newlines even inside raw string literals. + content := strings.ReplaceAll(literal[quote:], "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + if prefix == "r" { + value, _, err := bzl.Unquote("r" + content) + return value, err == nil + } + if !strings.Contains(content, "\\") { + value, _, err := bzl.Unquote(content) + return value, err == nil + } + // Buildtools already handles Python-style quotes and escapes. Normalize + // newlines and byte escapes to preserve Python 3's Unicode string semantics: + // buildtools interprets hex and octal escapes as bytes instead of code points. + var normalized strings.Builder + for len(content) > 0 { + if content[0] != '\\' { + normalized.WriteByte(content[0]) + content = content[1:] + continue + } + if len(content) < 2 { + return "", false + } + if content[1] == 'N' { + return "", false + } + if content[1] == 'x' || (content[1] >= '0' && content[1] <= '7') { + end := 2 + base := 8 + start := 1 + if content[1] == 'x' { + end, start, base = 4, 2, 16 + if len(content) < end { + return "", false + } + } else { + for end < len(content) && end < 4 && content[end] >= '0' && content[end] <= '7' { + end++ + } + } + r, err := strconv.ParseUint(content[start:end], base, 16) + if err != nil { + return "", false + } + fmt.Fprintf(&normalized, "\\u%04x", r) + content = content[end:] + continue + } + normalized.WriteString(content[:2]) + content = content[2:] + } + value, _, err := bzl.Unquote(normalized.String()) + return value, err == nil +} + +// parsePytestPlugins treats static module-level pytest_plugins declarations as +// imports. As with conditional imports, all statically declared alternatives are +// included; Python code is never executed to determine the active branch. +func (p *FileParser) parsePytestPlugins(node *sitter.Node) { + if p.inLocalScope || node.Type() != "assignment" { + return + } + left := node.ChildByFieldName("left") + if left == nil || left.Type() != sitterNodeTypeIdentifier || left.Content(p.code) != "pytest_plugins" { + return + } + right := node.ChildByFieldName("right") + for right != nil && right.Type() == "assignment" { + right = right.ChildByFieldName("right") + } + right = pytestPluginExpression(right) + if right == nil || right.HasError() { + return + } + add := func(value string, node *sitter.Node) { + if value != "" { + p.output.Modules = append(p.output.Modules, Module{ + Name: value, + LineNumber: node.StartPoint().Row + 1, + Filepath: p.relFilepath, + TypeCheckingOnly: p.inTypeCheckingBlock, + }) + } + } + if right.Type() == "list" || right.Type() == "tuple" || right.Type() == "expression_list" { + for i := 0; i < int(right.NamedChildCount()); i++ { + child := right.NamedChild(i) + if child.Type() == sitterNodeTypeComment { + continue + } + value, ok := pytestPluginString(child, p.code) + if !ok { + continue + } + add(value, child) + } + } else { + value, ok := pytestPluginString(right, p.code) + if !ok { + return + } + // Pytest also accepts comma-separated module names in a single string. + for _, name := range strings.Split(value, ",") { + add(name, right) + } + } +} + func (p *FileParser) parse(ctx context.Context, node *sitter.Node) { if node == nil { return @@ -248,6 +410,14 @@ func (p *FileParser) parse(ctx context.Context, node *sitter.Node) { // Check if this is a TYPE_CHECKING block wasInTypeCheckingBlock := p.inTypeCheckingBlock + wasInLocalScope := p.inLocalScope + defer func() { + p.inTypeCheckingBlock = wasInTypeCheckingBlock + p.inLocalScope = wasInLocalScope + }() + if node.Type() == "function_definition" || node.Type() == "class_definition" || node.Type() == "lambda" { + p.inLocalScope = true + } if p.isTypeCheckingBlock(node) { p.inTypeCheckingBlock = true } @@ -257,6 +427,7 @@ func (p *FileParser) parse(ctx context.Context, node *sitter.Node) { return } child := node.Child(i) + p.parsePytestPlugins(child) if p.parseImportStatements(child) { continue } @@ -266,8 +437,6 @@ func (p *FileParser) parse(ctx context.Context, node *sitter.Node) { p.parse(ctx, child) } - // Restore the previous state - p.inTypeCheckingBlock = wasInTypeCheckingBlock } func (p *FileParser) Parse(ctx context.Context) (*ParserOutput, error) { diff --git a/gazelle/python/file_parser_test.go b/gazelle/python/file_parser_test.go index 0a6fd1b4ab..9d56cb63d5 100644 --- a/gazelle/python/file_parser_test.go +++ b/gazelle/python/file_parser_test.go @@ -16,6 +16,7 @@ package python import ( "context" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -147,6 +148,151 @@ func TestParseImportStatements(t *testing.T) { } } +func TestParsePytestPlugins(t *testing.T) { + t.Parallel() + units := []struct { + name string + code string + want []string + }{ + {"string", `pytest_plugins = "foo.bar"`, []string{"foo.bar"}}, + {"single quotes", `pytest_plugins = 'foo.bar'`, []string{"foo.bar"}}, + {"list", `pytest_plugins = ["foo.bar", 'foo.baz']`, []string{"foo.bar", "foo.baz"}}, + {"tuple", `pytest_plugins = ("foo.bar", "foo.baz")`, []string{"foo.bar", "foo.baz"}}, + {"unparenthesized tuple", `pytest_plugins = "foo.bar", "foo.baz"`, []string{"foo.bar", "foo.baz"}}, + {"one element tuple", `pytest_plugins = ("foo.bar",)`, []string{"foo.bar"}}, + {"parentheses", `pytest_plugins = (("foo.bar"))`, []string{"foo.bar"}}, + {"parenthesized list", `pytest_plugins = (["foo.bar"])`, []string{"foo.bar"}}, + {"leading parenthesis comment", "pytest_plugins = (\n# comment\n'foo.bar'\n)", []string{"foo.bar"}}, + {"leading list parenthesis comment", "pytest_plugins = (\n# comment\n['foo.bar']\n)", []string{"foo.bar"}}, + {"parenthesized list item comment", "pytest_plugins = [(\n# comment\n'foo.bar'\n)]", []string{"foo.bar"}}, + {"annotated", `pytest_plugins: list[str] = ["foo.bar"]`, []string{"foo.bar"}}, + {"chained left", `pytest_plugins = other = ["foo.bar"]`, []string{"foo.bar"}}, + {"chained right", `other = pytest_plugins = ["foo.bar"]`, []string{"foo.bar"}}, + {"comma separated string", `pytest_plugins = "foo.bar,foo.baz"`, []string{"foo.bar", "foo.baz"}}, + {"comma in list string", `pytest_plugins = ["foo.bar,foo.baz"]`, []string{"foo.bar,foo.baz"}}, + {"raw", `pytest_plugins = R"foo.bar"`, []string{"foo.bar"}}, + {"unicode prefix", `pytest_plugins = u'foo.bar'`, []string{"foo.bar"}}, + {"triple quotes", `pytest_plugins = """foo.bar"""`, []string{"foo.bar"}}, + {"triple single quotes", `pytest_plugins = '''foo.bar'''`, []string{"foo.bar"}}, + {"raw triple quotes", `pytest_plugins = r'''foo.bar'''`, []string{"foo.bar"}}, + {"raw CRLF newlines", "pytest_plugins = r'''foo\r\nbar'''", []string{"foo\nbar"}}, + {"raw CR newlines", "pytest_plugins = r'''foo\rbar'''", []string{"foo\nbar"}}, + {"plain CRLF newlines", "pytest_plugins = '''foo\r\nbar'''", []string{"foo\nbar"}}, + {"non-ASCII module", `pytest_plugins = 'foo.café'`, []string{"foo.café"}}, + {"implicit concatenation", `pytest_plugins = "foo." 'bar'`, []string{"foo.bar"}}, + {"concatenation with comment", "pytest_plugins = (\"foo.\" # comment\n'bar')", []string{"foo.bar"}}, + {"escape sequences", `pytest_plugins = ["foo\x2ebar", 'foo\u002ebaz', "foo\U0000002equx", 'foo\056last']`, []string{"foo.bar", "foo.baz", "foo.qux", "foo.last"}}, + {"short octal", `pytest_plugins = 'foo\7bar'`, []string{"foo\abar"}}, + {"octal non-ASCII", `pytest_plugins = 'foo.\351'`, []string{"foo.é"}}, + {"hex non-ASCII", `pytest_plugins = 'foo.\xe9'`, []string{"foo.é"}}, + {"escaped backslash", `pytest_plugins = 'foo\\bar'`, []string{`foo\bar`}}, + {"control escapes", `pytest_plugins = '\a\b\f\n\r\t\v'`, []string{"\a\b\f\n\r\t\v"}}, + {"surrogate escape", `pytest_plugins = 'foo.\ud800'`, nil}, + {"out of range Unicode escape", `pytest_plugins = 'foo.\U00110000'`, nil}, + {"escaped quotes", `pytest_plugins = ["foo\"bar", 'foo\'baz']`, []string{"foo\"bar", "foo'baz"}}, + {"unrecognized escape", `pytest_plugins = 'foo\qbar'`, []string{`foo\qbar`}}, + {"raw escape", `pytest_plugins = r'foo\u002ebar'`, []string{`foo\u002ebar`}}, + {"continued string", "pytest_plugins = 'foo.\\\nbar'", []string{"foo.bar"}}, + {"continued CRLF string", "pytest_plugins = 'foo.\\\r\nbar'", []string{"foo.bar"}}, + {"conditional", "if condition:\n pytest_plugins = ['foo.bar']\nelse:\n pytest_plugins = ['foo.baz']", []string{"foo.bar", "foo.baz"}}, + {"reassignment", "pytest_plugins = ['foo.bar']\npytest_plugins = ['foo.baz']", []string{"foo.bar", "foo.baz"}}, + {"empty string", `pytest_plugins = ""`, nil}, + {"empty list", `pytest_plugins = []`, nil}, + {"empty tuple", `pytest_plugins = ()`, nil}, + {"comments in empty list", "pytest_plugins = [\n# comment\n]", nil}, + {"none", `pytest_plugins = None`, nil}, + {"annotation only", `pytest_plugins: list[str]`, nil}, + {"different name", `other_plugins = ["foo.bar"]`, nil}, + {"attribute", `module.pytest_plugins = ["foo.bar"]`, nil}, + {"unpacking", `pytest_plugins, other = ["foo.bar", "foo.baz"]`, nil}, + {"function local", "def f():\n pytest_plugins = ['foo.bar']", nil}, + {"class local", "class C:\n pytest_plugins = ['foo.bar']", nil}, + {"async function local", "async def f():\n pytest_plugins = ['foo.bar']", nil}, + {"decorated function local", "@decorate\ndef f():\n pytest_plugins = ['foo.bar']", nil}, + {"scope restored", "def f():\n pytest_plugins = ['foo.local']\npytest_plugins = ['foo.bar']", []string{"foo.bar"}}, + {"ordinary imports remain local", "def f():\n import foo.bar\n pytest_plugins = ['foo.local']", []string{"foo.bar"}}, + {"dynamic call", `pytest_plugins = plugins()`, nil}, + {"dynamic identifier", `pytest_plugins = plugins`, nil}, + {"dynamic list", `pytest_plugins = ["foo.bar", plugin]`, []string{"foo.bar"}}, + {"dynamic entries first", `pytest_plugins = [plugin, "foo.bar", *plugins, "foo.baz"]`, []string{"foo.bar", "foo.baz"}}, + {"mixed tuple", `pytest_plugins = (plugin, "foo.bar")`, []string{"foo.bar"}}, + {"unsupported string entry", `pytest_plugins = [f"{name}", "foo.bar"]`, []string{"foo.bar"}}, + {"escaped named escape text", `pytest_plugins = 'foo\\N{bar}'`, []string{`foo\N{bar}`}}, + {"raw named escape text", `pytest_plugins = r'foo\N{bar}'`, []string{`foo\N{bar}`}}, + {"dynamic concatenated string", `pytest_plugins = "foo." f"{name}"`, nil}, + {"nested list", `pytest_plugins = [["foo.bar"]]`, nil}, + {"starred list", `pytest_plugins = ["foo.bar", *plugins]`, []string{"foo.bar"}}, + {"comprehension", `pytest_plugins = [name for name in plugins]`, nil}, + {"addition", `pytest_plugins = ["foo.bar"] + plugins`, nil}, + {"augmented assignment", `pytest_plugins += ["foo.bar"]`, nil}, + {"set", `pytest_plugins = {"foo.bar"}`, nil}, + {"bytes", `pytest_plugins = b"foo.bar"`, nil}, + {"f-string", `pytest_plugins = f"foo.{name}"`, nil}, + {"constant f-string", `pytest_plugins = f"foo.bar"`, nil}, + {"malformed declaration", `pytest_plugins = [`, nil}, + {"malformed list", `pytest_plugins = ["foo.bar",`, nil}, + {"invalid escape", `pytest_plugins = "foo\xZZbar"`, nil}, + {"named Unicode escape", `pytest_plugins = "foo\N{FULL STOP}bar"`, nil}, + } + for _, unit := range units { + t.Run(unit.name, func(t *testing.T) { + p := NewFileParser() + p.SetCodeAndFile([]byte(unit.code), "tests", "example_test.py") + output, err := p.Parse(context.Background()) + assert.NoError(t, err) + var names []string + for _, module := range output.Modules { + names = append(names, module.Name) + assert.Equal(t, filepath.Join("tests", "example_test.py"), module.Filepath) + assert.Empty(t, module.From) + assert.False(t, module.TypeCheckingOnly) + } + assert.Equal(t, unit.want, names) + }) + } +} + +func TestParseRestoresStateOnCancellation(t *testing.T) { + t.Parallel() + for _, code := range []string{ + "def f():\n pytest_plugins = ['foo.bar']", + "if TYPE_CHECKING:\n import foo.bar", + } { + t.Run(code, func(t *testing.T) { + root, err := ParseCode([]byte(code), "example.py") + assert.NoError(t, err) + if root == nil { + t.Fatal("missing syntax tree") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + p := NewFileParser() + p.SetCodeAndFile([]byte(code), "", "example.py") + // Enter the scope directly so cancellation occurs after its state + // has changed, rather than before traversal reaches it. + p.parse(ctx, root.NamedChild(0)) + assert.False(t, p.inLocalScope) + assert.False(t, p.inTypeCheckingBlock) + assert.Empty(t, p.output.Modules) + }) + } +} + +func TestPytestPluginsLocationsAndComments(t *testing.T) { + t.Parallel() + p := NewFileParser() + p.SetCodeAndFile([]byte("pytest_plugins = [\n 'foo.bar', # gazelle:ignore foo.bar\n 'foo.baz',\n]\nif TYPE_CHECKING:\n pytest_plugins = 'foo.typing'\n"), "", "conftest.py") + output, err := p.Parse(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []Module{ + {Name: "foo.bar", LineNumber: 2, Filepath: "conftest.py"}, + {Name: "foo.baz", LineNumber: 3, Filepath: "conftest.py"}, + {Name: "foo.typing", LineNumber: 6, Filepath: "conftest.py", TypeCheckingOnly: true}, + }, output.Modules) + assert.Equal(t, []Comment{"# gazelle:ignore foo.bar"}, output.Comments) +} + func TestParseComments(t *testing.T) { t.Parallel() units := []struct { @@ -277,9 +423,9 @@ def example_function(): // Check that we found the expected modules expectedModules := map[string]bool{ - "sys": false, - "typing.TYPE_CHECKING": false, - "boto3": true, + "sys": false, + "typing.TYPE_CHECKING": false, + "boto3": true, "rest_framework.serializers": true, } diff --git a/gazelle/python/testdata/pytest_plugins/BUILD.in b/gazelle/python/testdata/pytest_plugins/BUILD.in new file mode 100644 index 0000000000..e2a9ba25e9 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/BUILD.in @@ -0,0 +1,5 @@ +# gazelle:python_root +# gazelle:python_generation_mode file +# gazelle:python_include_ancestor_conftest false +# gazelle:resolve py aliased_plugin //support:admin +# gazelle:python_ignore_dependencies ignored_plugin diff --git a/gazelle/python/testdata/pytest_plugins/BUILD.out b/gazelle/python/testdata/pytest_plugins/BUILD.out new file mode 100644 index 0000000000..1b3188b4f7 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/BUILD.out @@ -0,0 +1,23 @@ +load("@rules_python//python:defs.bzl", "py_test") + +# gazelle:python_root +# gazelle:python_generation_mode file +# gazelle:python_include_ancestor_conftest false +# gazelle:resolve py aliased_plugin //support:admin +# gazelle:python_ignore_dependencies ignored_plugin + +py_test( + name = "isolated_test", + srcs = ["isolated_test.py"], +) + +py_test( + name = "plugin_test", + srcs = ["plugin_test.py"], + deps = [ + "//support:admin", + "//support:database", + "@gazelle_python_test//external_plugin", + "@gazelle_python_test//pytest", + ], +) diff --git a/gazelle/python/testdata/pytest_plugins/README.md b/gazelle/python/testdata/pytest_plugins/README.md new file mode 100644 index 0000000000..4a64127b2d --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/README.md @@ -0,0 +1,7 @@ +# Pytest plugin dependencies + +Resolve literal plugin declarations in tests and reusable plugin +modules. Verify prerequisite chains, external modules, duplicate imports, +resolution overrides, dependency ignores, mixed dynamic entries, and unrelated +sibling test isolation. External and ignored module names are resolution inputs; +this fixture is not intended to execute pytest. diff --git a/gazelle/python/testdata/pytest_plugins/WORKSPACE b/gazelle/python/testdata/pytest_plugins/WORKSPACE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/pytest_plugins/gazelle_python.yaml b/gazelle/python/testdata/pytest_plugins/gazelle_python.yaml new file mode 100644 index 0000000000..753a230354 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/gazelle_python.yaml @@ -0,0 +1,5 @@ +manifest: + modules_mapping: + external_plugin: external_plugin + pytest: pytest + pip_deps_repository_name: gazelle_python_test diff --git a/gazelle/python/testdata/pytest_plugins/isolated_test.py b/gazelle/python/testdata/pytest_plugins/isolated_test.py new file mode 100644 index 0000000000..81d3472c39 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/isolated_test.py @@ -0,0 +1,2 @@ +def test_isolated(): + assert True diff --git a/gazelle/python/testdata/pytest_plugins/plugin_test.py b/gazelle/python/testdata/pytest_plugins/plugin_test.py new file mode 100644 index 0000000000..642cb213e6 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/plugin_test.py @@ -0,0 +1,22 @@ +import pytest + +plugin_name = "support.admin" +additional_plugins = [] + +# gazelle:ignore nonexistent.plugin +pytest_plugins = [ + "support.database", + "support.database", # Duplicate declarations resolve to one dependency. + "pytest", + "external_plugin", + "aliased_plugin", + "ignored_plugin", + "nonexistent.plugin", + # Dynamic entries do not hide the statically known dependencies above. + plugin_name, + *additional_plugins, +] + + +def test_database(database): + assert database == "database" diff --git a/gazelle/python/testdata/pytest_plugins/support/BUILD.in b/gazelle/python/testdata/pytest_plugins/support/BUILD.in new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/pytest_plugins/support/BUILD.out b/gazelle/python/testdata/pytest_plugins/support/BUILD.out new file mode 100644 index 0000000000..0e40491368 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/support/BUILD.out @@ -0,0 +1,18 @@ +load("@rules_python//python:defs.bzl", "py_library") + +py_library( + name = "admin", + srcs = ["admin.py"], + visibility = ["//:__subpackages__"], + deps = ["@gazelle_python_test//pytest"], +) + +py_library( + name = "database", + srcs = ["database.py"], + visibility = ["//:__subpackages__"], + deps = [ + ":admin", + "@gazelle_python_test//pytest", + ], +) diff --git a/gazelle/python/testdata/pytest_plugins/support/admin.py b/gazelle/python/testdata/pytest_plugins/support/admin.py new file mode 100644 index 0000000000..2b1ecb59b9 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/support/admin.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture(name="admin") +def fixture_admin(): + return "database" diff --git a/gazelle/python/testdata/pytest_plugins/support/database.py b/gazelle/python/testdata/pytest_plugins/support/database.py new file mode 100644 index 0000000000..28ca3b3248 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/support/database.py @@ -0,0 +1,11 @@ +import pytest + +pytest_plugins = ( + # A comment before a parenthesized value must not hide its dependency. + ("support.admin",) +) + + +@pytest.fixture(name="database") +def fixture_database(admin): + return admin diff --git a/gazelle/python/testdata/pytest_plugins/test.yaml b/gazelle/python/testdata/pytest_plugins/test.yaml new file mode 100644 index 0000000000..7c9bd598fd --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/test.yaml @@ -0,0 +1,2 @@ +expect: + exit_code: 0 diff --git a/gazelle/python/testdata/pytest_plugins_conftest/BUILD.in b/gazelle/python/testdata/pytest_plugins_conftest/BUILD.in new file mode 100644 index 0000000000..af2c2cea4b --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/BUILD.in @@ -0,0 +1 @@ +# gazelle:python_generation_mode file diff --git a/gazelle/python/testdata/pytest_plugins_conftest/BUILD.out b/gazelle/python/testdata/pytest_plugins_conftest/BUILD.out new file mode 100644 index 0000000000..194d7566c0 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/BUILD.out @@ -0,0 +1,24 @@ +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +# gazelle:python_generation_mode file + +py_library( + name = "fixtures", + srcs = ["fixtures.py"], + visibility = ["//:__subpackages__"], + deps = ["@gazelle_python_test//pytest"], +) + +py_library( + name = "conftest", + testonly = True, + srcs = ["conftest.py"], + visibility = ["//:__subpackages__"], + deps = [":fixtures"], +) + +py_test( + name = "consumer_test", + srcs = ["consumer_test.py"], + deps = [":conftest"], +) diff --git a/gazelle/python/testdata/pytest_plugins_conftest/README.md b/gazelle/python/testdata/pytest_plugins_conftest/README.md new file mode 100644 index 0000000000..e49b65957b --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/README.md @@ -0,0 +1,5 @@ +# Root conftest plugins + +Resolve a root conftest's plugin declaration and include its dependencies in the +consumer test. Unlike nested conftest plugin declarations, this layout can also +be collected by pytest from the workspace root. diff --git a/gazelle/python/testdata/pytest_plugins_conftest/WORKSPACE b/gazelle/python/testdata/pytest_plugins_conftest/WORKSPACE new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gazelle/python/testdata/pytest_plugins_conftest/conftest.py b/gazelle/python/testdata/pytest_plugins_conftest/conftest.py new file mode 100644 index 0000000000..159cf35927 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/conftest.py @@ -0,0 +1 @@ +pytest_plugins = "fixtures" diff --git a/gazelle/python/testdata/pytest_plugins_conftest/consumer_test.py b/gazelle/python/testdata/pytest_plugins_conftest/consumer_test.py new file mode 100644 index 0000000000..2fa0f08107 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/consumer_test.py @@ -0,0 +1,2 @@ +def test_database(database): + assert database == "database" diff --git a/gazelle/python/testdata/pytest_plugins_conftest/fixtures.py b/gazelle/python/testdata/pytest_plugins_conftest/fixtures.py new file mode 100644 index 0000000000..158882ffc7 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/fixtures.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture(name="database") +def fixture_database(): + return "database" diff --git a/gazelle/python/testdata/pytest_plugins_conftest/gazelle_python.yaml b/gazelle/python/testdata/pytest_plugins_conftest/gazelle_python.yaml new file mode 100644 index 0000000000..bbba7661d3 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/gazelle_python.yaml @@ -0,0 +1,4 @@ +manifest: + modules_mapping: + pytest: pytest + pip_deps_repository_name: gazelle_python_test diff --git a/gazelle/python/testdata/pytest_plugins_conftest/test.yaml b/gazelle/python/testdata/pytest_plugins_conftest/test.yaml new file mode 100644 index 0000000000..7c9bd598fd --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/test.yaml @@ -0,0 +1,2 @@ +expect: + exit_code: 0 diff --git a/news/4196.added.md b/news/4196.added.md new file mode 100644 index 0000000000..8709858225 --- /dev/null +++ b/news/4196.added.md @@ -0,0 +1,2 @@ +(gazelle) Resolve literal `pytest_plugins` declarations into Python target +dependencies, including reusable plugins' prerequisite declarations.