diff --git a/gazelle/docs/installation_and_usage.md b/gazelle/docs/installation_and_usage.md index 3a8733cda8..453e59a141 100644 --- a/gazelle/docs/installation_and_usage.md +++ b/gazelle/docs/installation_and_usage.md @@ -206,6 +206,31 @@ 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. +Built-in plugin aliases such as `pytester`, `capture`, and `logging` resolve to +their `_pytest` modules from the pytest distribution. Ignores and resolution +overrides still match the declared alias. Ordinary imports are not remapped. + +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/BUILD.bazel b/gazelle/python/BUILD.bazel index 1ffa2890e1..e2b1b0a79b 100644 --- a/gazelle/python/BUILD.bazel +++ b/gazelle/python/BUILD.bazel @@ -124,6 +124,7 @@ go_test( ], embed = [":python"], deps = [ + "@com_github_emirpasic_gods//sets/treeset:go_default_library", "@com_github_stretchr_testify//assert", ], ) diff --git a/gazelle/python/file_parser.go b/gazelle/python/file_parser.go index e129337e11..0a8f0ace69 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,166 @@ 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, + PytestPlugin: true, + }) + } + } + 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 +411,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 +428,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 +438,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..347fcbf963 100644 --- a/gazelle/python/file_parser_test.go +++ b/gazelle/python/file_parser_test.go @@ -16,8 +16,10 @@ package python import ( "context" + "path/filepath" "testing" + "github.com/emirpasic/gods/sets/treeset" "github.com/stretchr/testify/assert" ) @@ -147,6 +149,170 @@ 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", PytestPlugin: true}, + {Name: "foo.baz", LineNumber: 3, Filepath: "conftest.py", PytestPlugin: true}, + {Name: "foo.typing", LineNumber: 6, Filepath: "conftest.py", TypeCheckingOnly: true, PytestPlugin: true}, + }, output.Modules) + assert.Equal(t, []Comment{"# gazelle:ignore foo.bar"}, output.Comments) +} + +func TestPluginAndImportDependenciesRemainDistinct(t *testing.T) { + t.Parallel() + for _, pluginFirst := range []bool{false, true} { + modules := treeset.NewWith(moduleComparator) + ordinary := Module{Name: "logging", TypeCheckingOnly: true} + plugin := Module{Name: "logging", PytestPlugin: true} + if pluginFirst { + addModuleToTreeSet(modules, plugin) + addModuleToTreeSet(modules, ordinary) + } else { + addModuleToTreeSet(modules, ordinary) + addModuleToTreeSet(modules, plugin) + } + assert.Equal(t, []interface{}{ordinary, plugin}, modules.Values()) + addModuleToTreeSet(modules, Module{Name: "logging", PytestPlugin: true, TypeCheckingOnly: true}) + assert.Equal(t, []interface{}{ordinary, plugin}, modules.Values()) + } +} + func TestParseComments(t *testing.T) { t.Parallel() units := []struct { @@ -277,9 +443,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/parser.go b/gazelle/python/parser.go index 3d0dbe7a5f..50cbea16b0 100644 --- a/gazelle/python/parser.go +++ b/gazelle/python/parser.go @@ -163,11 +163,24 @@ type Module struct { From string `json:"from"` // Whether this import is type-checking only (inside if TYPE_CHECKING block). TypeCheckingOnly bool `json:"type_checking_only"` + // Whether the name was declared in pytest_plugins, whose built-in aliases + // resolve differently from ordinary Python imports. + PytestPlugin bool `json:"pytest_plugin"` } -// moduleComparator compares modules by name. +// moduleComparator compares modules by name and plugin declaration status. func moduleComparator(a, b interface{}) int { - return godsutils.StringComparator(a.(Module).Name, b.(Module).Name) + left, right := a.(Module), b.(Module) + if cmp := godsutils.StringComparator(left.Name, right.Name); cmp != 0 { + return cmp + } + if left.PytestPlugin == right.PytestPlugin { + return 0 + } + if left.PytestPlugin { + return 1 + } + return -1 } // addModuleToTreeSet adds a module to a treeset.Set, ensuring that a TypeCheckingOnly=false module is diff --git a/gazelle/python/resolve.go b/gazelle/python/resolve.go index cc57180a49..054ecd7b83 100644 --- a/gazelle/python/resolve.go +++ b/gazelle/python/resolve.go @@ -45,6 +45,26 @@ const ( // in rules generated by this extension. type Resolver struct{} +// pytestPluginModule mirrors pytest's builtin_plugins in _pytest/config/__init__.py. +// Keep the declared name on Module so ignores and resolve overrides still match +// it. Only plugin declarations use these aliases; ordinary imports do not. +func pytestPluginModule(name string) string { + switch name { + case "mark", "main", "runner", "fixtures", "helpconfig", "python", + "terminal", "debugging", "unittest", "capture", "skipping", "legacypath", + "tmpdir", "monkeypatch", "recwarn", "pastebin", "assertion", "junitxml", + "doctest", "cacheprovider", "setuponly", "setupplan", "stepwise", + "unraisableexception", "threadexception", "warnings", "logging", + "reports", "faulthandler", "subtests", "pytester", "pytester_assertions", + "terminalprogress", + // Built-ins in older pytest releases. + "nose", "freeze_support": + return "_pytest." + name + default: + return name + } +} + // Name returns the name of the language. This is the prefix of the kinds of // rules generated. E.g. py_library and py_binary. func (*Resolver) Name() string { return languageName } @@ -169,6 +189,10 @@ func (py *Resolver) Resolve( for it.Next() { mod := it.Value().(Module) moduleName := mod.Name + if mod.PytestPlugin { + moduleName = pytestPluginModule(moduleName) + } + builtinPluginAlias := mod.PytestPlugin && moduleName != mod.Name // Transform relative imports `.` or `..foo.bar` into the package path from root. if strings.HasPrefix(mod.From, ".") { if !cfg.ExperimentalAllowRelativeImports() { @@ -229,6 +253,11 @@ func (py *Resolver) Resolve( moduleParts = moduleParts[:len(moduleParts)-1] possibleModules = append(possibleModules, strings.Join(moduleParts, ".")) } + if builtinPluginAlias { + // Give explicit overrides for the declared alias precedence, but + // do not resolve that alias as a local, third-party, or stdlib module. + possibleModules = append([]string{mod.Name}, possibleModules...) + } errs := []error{} POSSIBLE_MODULE_LOOP: for _, moduleName := range possibleModules { @@ -252,6 +281,9 @@ func (py *Resolver) Resolve( continue MODULES_LOOP } } else { + if builtinPluginAlias && moduleName == mod.Name { + continue POSSIBLE_MODULE_LOOP + } if dep, distributionName, ok := cfg.FindThirdPartyDependency(moduleName); ok { addDependency(dep, mod.TypeCheckingOnly, deps, pyiDeps) // Add the type and stub dependencies if they exist. diff --git a/gazelle/python/testdata/pytest_plugins/BUILD.in b/gazelle/python/testdata/pytest_plugins/BUILD.in new file mode 100644 index 0000000000..7c16102fdb --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/BUILD.in @@ -0,0 +1,6 @@ +# 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,monkeypatch +# gazelle:resolve py terminal //support:admin diff --git a/gazelle/python/testdata/pytest_plugins/BUILD.out b/gazelle/python/testdata/pytest_plugins/BUILD.out new file mode 100644 index 0000000000..0c232995e6 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/BUILD.out @@ -0,0 +1,63 @@ +load("@rules_python//python:defs.bzl", "py_library", "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,monkeypatch +# gazelle:resolve py terminal //support:admin + +py_library( + name = "capture", + srcs = ["capture.py"], + visibility = ["//:__subpackages__"], +) + +py_test( + name = "builtin_controls_test", + srcs = ["builtin_controls_test.py"], + deps = ["//support:admin"], +) + +py_test( + name = "builtin_plugins_test", + srcs = ["builtin_plugins_test.py"], + deps = ["@gazelle_python_test//pytest"], +) + +py_test( + name = "isolated_test", + srcs = ["isolated_test.py"], +) + +py_test( + name = "logging_plugin_test", + srcs = ["logging_plugin_test.py"], + deps = ["@gazelle_python_test//pytest"], +) + +py_test( + name = "ordinary_import_test", + srcs = ["ordinary_import_test.py"], + deps = [ + ":capture", + "@gazelle_python_test//pytest", + ], +) + +py_test( + name = "plugin_test", + srcs = ["plugin_test.py"], + deps = [ + "//support:admin", + "//support:database", + "@gazelle_python_test//external_plugin", + "@gazelle_python_test//pytest", + ], +) + +py_test( + name = "pytester_plugin_test", + srcs = ["pytester_plugin_test.py"], + deps = ["@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..0a3caa4dc6 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/README.md @@ -0,0 +1,10 @@ +# Pytest plugin dependencies + +Also covers pytest built-in aliases (including stdlib-name collisions), alias +ignores and overrides, and ordinary imports sharing a plugin alias's name. + +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/builtin_controls_test.py b/gazelle/python/testdata/pytest_plugins/builtin_controls_test.py new file mode 100644 index 0000000000..0a7c561d26 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/builtin_controls_test.py @@ -0,0 +1,3 @@ +# Ignores use declared names, and the terminal alias has an explicit override. +# gazelle:ignore pytester +pytest_plugins = ["pytester", "terminal", "monkeypatch"] diff --git a/gazelle/python/testdata/pytest_plugins/builtin_plugins_test.py b/gazelle/python/testdata/pytest_plugins/builtin_plugins_test.py new file mode 100644 index 0000000000..d4a5571256 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/builtin_plugins_test.py @@ -0,0 +1,7 @@ +pytest_plugins = ( + "mark,main,runner,fixtures,helpconfig,python,debugging,unittest,capture," + "skipping,legacypath,tmpdir,monkeypatch,recwarn,pastebin,assertion,junitxml," + "doctest,cacheprovider,setuponly,setupplan,stepwise,unraisableexception," + "threadexception,warnings,reports,faulthandler,subtests,pytester_assertions," + "terminalprogress,nose,freeze_support" +) diff --git a/gazelle/python/testdata/pytest_plugins/capture.py b/gazelle/python/testdata/pytest_plugins/capture.py new file mode 100644 index 0000000000..a614f258ee --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/capture.py @@ -0,0 +1 @@ +"""A local module whose name also happens to be a built-in pytest plugin alias.""" 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..e0d828a398 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/gazelle_python.yaml @@ -0,0 +1,6 @@ +manifest: + modules_mapping: + _pytest: pytest + 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/logging_plugin_test.py b/gazelle/python/testdata/pytest_plugins/logging_plugin_test.py new file mode 100644 index 0000000000..f1f84002b2 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/logging_plugin_test.py @@ -0,0 +1,4 @@ +import logging + +# Unlike the ordinary import above, this alias needs the pytest wheel. +pytest_plugins = ["logging"] diff --git a/gazelle/python/testdata/pytest_plugins/ordinary_import_test.py b/gazelle/python/testdata/pytest_plugins/ordinary_import_test.py new file mode 100644 index 0000000000..01fa960fe3 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/ordinary_import_test.py @@ -0,0 +1,4 @@ +import capture + +# Keep both dependencies even though the ordinary import and alias share a name. +pytest_plugins = ["capture"] 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/pytester_plugin_test.py b/gazelle/python/testdata/pytest_plugins/pytester_plugin_test.py new file mode 100644 index 0000000000..434f853aee --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins/pytester_plugin_test.py @@ -0,0 +1 @@ +pytest_plugins = ["pytester", "_pytest.pytester"] 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..f0e40df9d4 --- /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 = "database_fixtures", + srcs = ["database_fixtures.py"], + visibility = ["//:__subpackages__"], + deps = ["@gazelle_python_test//pytest"], +) + +py_library( + name = "conftest", + testonly = True, + srcs = ["conftest.py"], + visibility = ["//:__subpackages__"], + deps = [":database_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..788c244036 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/README.md @@ -0,0 +1,6 @@ +# 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. +The local plugin avoids pytest's reserved built-in alias `fixtures`. 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..8c2e7e67c7 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/conftest.py @@ -0,0 +1 @@ +pytest_plugins = "database_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/database_fixtures.py b/gazelle/python/testdata/pytest_plugins_conftest/database_fixtures.py new file mode 100644 index 0000000000..158882ffc7 --- /dev/null +++ b/gazelle/python/testdata/pytest_plugins_conftest/database_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.