// Copyright 2023 The Bazel Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package python import ( "fmt" "io/fs" "log" "os" "path/filepath" "sort" "strings" "github.com/bazelbuild/bazel-gazelle/config" "github.com/bazelbuild/bazel-gazelle/label" "github.com/bazelbuild/bazel-gazelle/language" "github.com/bazelbuild/bazel-gazelle/rule" "github.com/bmatcuk/doublestar/v4" "github.com/emirpasic/gods/lists/singlylinkedlist" "github.com/emirpasic/gods/sets/treeset" godsutils "github.com/emirpasic/gods/utils" "github.com/bazelbuild/rules_python/gazelle/pythonconfig" ) const ( pyLibraryEntrypointFilename = "__init__.py" pyBinaryEntrypointFilename = "__main__.py" pyTestEntrypointFilename = "__test__.py" pyTestEntrypointTargetname = "__test__" conftestFilename = "conftest.py" conftestTargetname = "conftest" ) var ( buildFilenames = []string{"BUILD", "BUILD.bazel"} ) func GetActualKindName(kind string, args language.GenerateArgs) string { if kindOverride, ok := args.Config.KindMap[kind]; ok { return kindOverride.KindName } return kind } func matchesAnyGlob(s string, globs []string) bool { // This function assumes that the globs have already been validated. If a glob is // invalid, it's considered a non-match and we move on to the next pattern. for _, g := range globs { if ok, _ := doublestar.Match(g, s); ok { return true } } return false } // GenerateRules extracts build metadata from source files in a directory. // GenerateRules is called in each directory where an update is requested // in depth-first post-order. func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateResult { cfgs := args.Config.Exts[languageName].(pythonconfig.Configs) cfg := cfgs[args.Rel] if !cfg.ExtensionEnabled() { return language.GenerateResult{} } if !isBazelPackage(args.Dir) { if cfg.CoarseGrainedGeneration() { // Determine if the current directory is the root of the coarse-grained // generation. If not, return without generating anything. parent := cfg.Parent() if parent != nil && parent.CoarseGrainedGeneration() { return language.GenerateResult{} } } else if !hasEntrypointFile(args.Dir) { return language.GenerateResult{} } } actualPyBinaryKind := GetActualKindName(pyBinaryKind, args) actualPyLibraryKind := GetActualKindName(pyLibraryKind, args) actualPyTestKind := GetActualKindName(pyTestKind, args) pythonProjectRoot := cfg.PythonProjectRoot() packageName := filepath.Base(args.Dir) pyLibraryFilenames := treeset.NewWith(godsutils.StringComparator) pyTestFilenames := treeset.NewWith(godsutils.StringComparator) pyFileNames := treeset.NewWith(godsutils.StringComparator) // hasPyBinaryEntryPointFile controls whether a single py_binary target should be generated for // this package or not. hasPyBinaryEntryPointFile := false // hasPyTestEntryPointFile and hasPyTestEntryPointTarget control whether a py_test target should // be generated for this package or not. hasPyTestEntryPointFile := false hasPyTestEntryPointTarget := false hasConftestFile := false testFileGlobs := cfg.TestFilePattern() for _, f := range args.RegularFiles { if cfg.IgnoresFile(filepath.Base(f)) { continue } ext := filepath.Ext(f) if ext == ".py" { pyFileNames.Add(f) if !hasPyBinaryEntryPointFile && f == pyBinaryEntrypointFilename { hasPyBinaryEntryPointFile = true } else if !hasPyTestEntryPointFile && f == pyTestEntrypointFilename { hasPyTestEntryPointFile = true } else if f == conftestFilename { hasConftestFile = true } else if matchesAnyGlob(f, testFileGlobs) { pyTestFilenames.Add(f) } else { pyLibraryFilenames.Add(f) } } } // If a __test__.py file was not found on disk, search for targets that are // named __test__. if !hasPyTestEntryPointFile && args.File != nil { for _, rule := range args.File.Rules { if rule.Name() == pyTestEntrypointTargetname { hasPyTestEntryPointTarget = true break } } } // Add files from subdirectories if they meet the criteria. for _, d := range args.Subdirs { // boundaryPackages represents child Bazel packages that are used as a // boundary to stop processing under that tree. boundaryPackages := make(map[string]struct{}) err := filepath.WalkDir( filepath.Join(args.Dir, d), func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } // Ignore the path if it crosses any boundary package. Walking // the tree is still important because subsequent paths can // represent files that have not crossed any boundaries. for bp := range boundaryPackages { if strings.HasPrefix(path, bp) { return nil } } if entry.IsDir() { // If we are visiting a directory, we determine if we should // halt digging the tree based on a few criterias: // 1. We are using per-file generation. // 2. The directory has a BUILD or BUILD.bazel files. Then // it doesn't matter at all what it has since it's a // separate Bazel package. // 3. (only for package generation) The directory has an // __init__.py, __main__.py or __test__.py, meaning a // BUILD file will be generated. if cfg.PerFileGeneration() { return fs.SkipDir } if isBazelPackage(path) { boundaryPackages[path] = struct{}{} return nil } if !cfg.CoarseGrainedGeneration() && hasEntrypointFile(path) { return fs.SkipDir } return nil } if filepath.Ext(path) == ".py" { if cfg.CoarseGrainedGeneration() || !isEntrypointFile(path) { srcPath, _ := filepath.Rel(args.Dir, path) repoPath := filepath.Join(args.Rel, srcPath) excludedPatterns := cfg.ExcludedPatterns() if excludedPatterns != nil { it := excludedPatterns.Iterator() for it.Next() { excludedPattern := it.Value().(string) isExcluded, err := doublestar.Match(excludedPattern, repoPath) if err != nil { return err } if isExcluded { return nil } } } baseName := filepath.Base(path) if matchesAnyGlob(baseName, testFileGlobs) { pyTestFilenames.Add(srcPath) } else { pyLibraryFilenames.Add(srcPath) } } } return nil }, ) if err != nil { log.Printf("ERROR: %v\n", err) return language.GenerateResult{} } } parser := newPython3Parser(args.Config.RepoRoot, args.Rel, cfg.IgnoresDependency) visibility := cfg.Visibility() var result language.GenerateResult result.Gen = make([]*rule.Rule, 0) collisionErrors := singlylinkedlist.New() appendPyLibrary := func(srcs *treeset.Set, pyLibraryTargetName string) { allDeps, mainModules, annotations, err := parser.parse(srcs) if err != nil { log.Fatalf("ERROR: %v\n", err) } if !hasPyBinaryEntryPointFile { // Creating one py_binary target per main module when __main__.py doesn't exist. mainFileNames := make([]string, 0, len(mainModules)) for name := range mainModules { mainFileNames = append(mainFileNames, name) // Remove the file from srcs if we're doing per-file library generation so // that we don't also generate a py_library target for it. if cfg.PerFileGeneration() { srcs.Remove(name) } } sort.Strings(mainFileNames) for _, filename := range mainFileNames { pyBinaryTargetName := strings.TrimSuffix(filepath.Base(filename), ".py") if err := ensureNoCollision(args.File, pyBinaryTargetName, actualPyBinaryKind); err != nil { fqTarget := label.New("", args.Rel, pyBinaryTargetName) log.Printf("failed to generate target %q of kind %q: %v", fqTarget.String(), actualPyBinaryKind, err) continue } pyBinary := newTargetBuilder(pyBinaryKind, pyBinaryTargetName, pythonProjectRoot, args.Rel, pyFileNames). addVisibility(visibility). addSrc(filename). addModuleDependencies(mainModules[filename]). addResolvedDependencies(annotations.includeDeps). generateImportsAttribute().build() result.Gen = append(result.Gen, pyBinary) result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey)) } } // If we're doing per-file generation, srcs could be empty at this point, meaning we shouldn't make a py_library. // If there is already a package named py_library target before, we should generate an empty py_library. if srcs.Empty() { if args.File == nil { return } generateEmptyLibrary := false for _, r := range args.File.Rules { if r.Kind() == actualPyLibraryKind && r.Name() == pyLibraryTargetName { generateEmptyLibrary = true } } if !generateEmptyLibrary { return } } // Check if a target with the same name we are generating already // exists, and if it is of a different kind from the one we are // generating. If so, we have to throw an error since Gazelle won't // generate it correctly. if err := ensureNoCollision(args.File, pyLibraryTargetName, actualPyLibraryKind); err != nil { fqTarget := label.New("", args.Rel, pyLibraryTargetName) err := fmt.Errorf("failed to generate target %q of kind %q: %w. "+ "Use the '# gazelle:%s' directive to change the naming convention.", fqTarget.String(), actualPyLibraryKind, err, pythonconfig.LibraryNamingConvention) collisionErrors.Add(err) } pyLibrary := newTargetBuilder(pyLibraryKind, pyLibraryTargetName, pythonProjectRoot, args.Rel, pyFileNames). addVisibility(visibility). addSrcs(srcs). addModuleDependencies(allDeps). addResolvedDependencies(annotations.includeDeps). generateImportsAttribute(). build() if pyLibrary.IsEmpty(py.Kinds()[pyLibrary.Kind()]) { result.Empty = append(result.Gen, pyLibrary) } else { result.Gen = append(result.Gen, pyLibrary) result.Imports = append(result.Imports, pyLibrary.PrivateAttr(config.GazelleImportsKey)) } } if cfg.PerFileGeneration() { hasInit, nonEmptyInit := hasLibraryEntrypointFile(args.Dir) pyLibraryFilenames.Each(func(index int, filename interface{}) { pyLibraryTargetName := strings.TrimSuffix(filepath.Base(filename.(string)), ".py") if filename == pyLibraryEntrypointFilename && !nonEmptyInit { return // ignore empty __init__.py. } srcs := treeset.NewWith(godsutils.StringComparator, filename) if cfg.PerFileGenerationIncludeInit() && hasInit && nonEmptyInit { srcs.Add(pyLibraryEntrypointFilename) } appendPyLibrary(srcs, pyLibraryTargetName) }) } else { appendPyLibrary(pyLibraryFilenames, cfg.RenderLibraryName(packageName)) } if hasPyBinaryEntryPointFile { deps, _, annotations, err := parser.parseSingle(pyBinaryEntrypointFilename) if err != nil { log.Fatalf("ERROR: %v\n", err) } pyBinaryTargetName := cfg.RenderBinaryName(packageName) // Check if a target with the same name we are generating already // exists, and if it is of a different kind from the one we are // generating. If so, we have to throw an error since Gazelle won't // generate it correctly. if err := ensureNoCollision(args.File, pyBinaryTargetName, actualPyBinaryKind); err != nil { fqTarget := label.New("", args.Rel, pyBinaryTargetName) err := fmt.Errorf("failed to generate target %q of kind %q: %w. "+ "Use the '# gazelle:%s' directive to change the naming convention.", fqTarget.String(), actualPyBinaryKind, err, pythonconfig.BinaryNamingConvention) collisionErrors.Add(err) } pyBinaryTarget := newTargetBuilder(pyBinaryKind, pyBinaryTargetName, pythonProjectRoot, args.Rel, pyFileNames). setMain(pyBinaryEntrypointFilename). addVisibility(visibility). addSrc(pyBinaryEntrypointFilename). addModuleDependencies(deps). addResolvedDependencies(annotations.includeDeps). generateImportsAttribute() pyBinary := pyBinaryTarget.build() result.Gen = append(result.Gen, pyBinary) result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey)) } var conftest *rule.Rule if hasConftestFile { deps, _, annotations, err := parser.parseSingle(conftestFilename) if err != nil { log.Fatalf("ERROR: %v\n", err) } // Check if a target with the same name we are generating already // exists, and if it is of a different kind from the one we are // generating. If so, we have to throw an error since Gazelle won't // generate it correctly. if err := ensureNoCollision(args.File, conftestTargetname, actualPyLibraryKind); err != nil { fqTarget := label.New("", args.Rel, conftestTargetname) err := fmt.Errorf("failed to generate target %q of kind %q: %w. ", fqTarget.String(), actualPyLibraryKind, err) collisionErrors.Add(err) } conftestTarget := newTargetBuilder(pyLibraryKind, conftestTargetname, pythonProjectRoot, args.Rel, pyFileNames). addSrc(conftestFilename). addModuleDependencies(deps). addResolvedDependencies(annotations.includeDeps). addVisibility(visibility). setTestonly(). generateImportsAttribute() conftest = conftestTarget.build() result.Gen = append(result.Gen, conftest) result.Imports = append(result.Imports, conftest.PrivateAttr(config.GazelleImportsKey)) } var pyTestTargets []*targetBuilder newPyTestTargetBuilder := func(srcs *treeset.Set, pyTestTargetName string) *targetBuilder { deps, _, annotations, err := parser.parse(srcs) if err != nil { log.Fatalf("ERROR: %v\n", err) } // Check if a target with the same name we are generating already // exists, and if it is of a different kind from the one we are // generating. If so, we have to throw an error since Gazelle won't // generate it correctly. if err := ensureNoCollision(args.File, pyTestTargetName, actualPyTestKind); err != nil { fqTarget := label.New("", args.Rel, pyTestTargetName) err := fmt.Errorf("failed to generate target %q of kind %q: %w. "+ "Use the '# gazelle:%s' directive to change the naming convention.", fqTarget.String(), actualPyTestKind, err, pythonconfig.TestNamingConvention) collisionErrors.Add(err) } return newTargetBuilder(pyTestKind, pyTestTargetName, pythonProjectRoot, args.Rel, pyFileNames). addSrcs(srcs). addModuleDependencies(deps). addResolvedDependencies(annotations.includeDeps). generateImportsAttribute() } if (!cfg.PerPackageGenerationRequireTestEntryPoint() || hasPyTestEntryPointFile || hasPyTestEntryPointTarget || cfg.CoarseGrainedGeneration()) && !cfg.PerFileGeneration() { // Create one py_test target per package if hasPyTestEntryPointFile { // Only add the pyTestEntrypointFilename to the pyTestFilenames if // the file exists on disk. pyTestFilenames.Add(pyTestEntrypointFilename) } if hasPyTestEntryPointTarget || !pyTestFilenames.Empty() { pyTestTargetName := cfg.RenderTestName(packageName) pyTestTarget := newPyTestTargetBuilder(pyTestFilenames, pyTestTargetName) if hasPyTestEntryPointTarget { entrypointTarget := fmt.Sprintf(":%s", pyTestEntrypointTargetname) main := fmt.Sprintf(":%s", pyTestEntrypointFilename) pyTestTarget. addSrc(entrypointTarget). addResolvedDependency(entrypointTarget). setMain(main) } else if hasPyTestEntryPointFile { pyTestTarget.setMain(pyTestEntrypointFilename) } /* else: main is not set, assuming there is a test file with the same name as the target name, or there is a macro wrapping py_test and setting its main attribute. */ pyTestTargets = append(pyTestTargets, pyTestTarget) } } else { // Create one py_test target per file pyTestFilenames.Each(func(index int, testFile interface{}) { srcs := treeset.NewWith(godsutils.StringComparator, testFile) pyTestTargetName := strings.TrimSuffix(filepath.Base(testFile.(string)), ".py") pyTestTarget := newPyTestTargetBuilder(srcs, pyTestTargetName) if hasPyTestEntryPointTarget { entrypointTarget := fmt.Sprintf(":%s", pyTestEntrypointTargetname) main := fmt.Sprintf(":%s", pyTestEntrypointFilename) pyTestTarget. addSrc(entrypointTarget). addResolvedDependency(entrypointTarget). setMain(main) } else if hasPyTestEntryPointFile { pyTestTarget.addSrc(pyTestEntrypointFilename) pyTestTarget.setMain(pyTestEntrypointFilename) } pyTestTargets = append(pyTestTargets, pyTestTarget) }) } for _, pyTestTarget := range pyTestTargets { if conftest != nil { pyTestTarget.addModuleDependency(module{Name: strings.TrimSuffix(conftestFilename, ".py")}) } pyTest := pyTestTarget.build() result.Gen = append(result.Gen, pyTest) result.Imports = append(result.Imports, pyTest.PrivateAttr(config.GazelleImportsKey)) } if !collisionErrors.Empty() { it := collisionErrors.Iterator() for it.Next() { log.Printf("ERROR: %v\n", it.Value()) } os.Exit(1) } return result } // isBazelPackage determines if the directory is a Bazel package by probing for // the existence of a known BUILD file name. func isBazelPackage(dir string) bool { for _, buildFilename := range buildFilenames { path := filepath.Join(dir, buildFilename) if _, err := os.Stat(path); err == nil { return true } } return false } // hasEntrypointFile determines if the directory has any of the established // entrypoint filenames. func hasEntrypointFile(dir string) bool { for _, entrypointFilename := range []string{ pyLibraryEntrypointFilename, pyBinaryEntrypointFilename, pyTestEntrypointFilename, } { path := filepath.Join(dir, entrypointFilename) if _, err := os.Stat(path); err == nil { return true } } return false } // hasLibraryEntrypointFile returns if the given directory has the library // entrypoint file, and if it is non-empty. func hasLibraryEntrypointFile(dir string) (bool, bool) { stat, err := os.Stat(filepath.Join(dir, pyLibraryEntrypointFilename)) if os.IsNotExist(err) { return false, false } if err != nil { log.Fatalf("ERROR: %v\n", err) } return true, stat.Size() != 0 } // isEntrypointFile returns whether the given path is an entrypoint file. The // given path can be absolute or relative. func isEntrypointFile(path string) bool { basePath := filepath.Base(path) switch basePath { case pyLibraryEntrypointFilename, pyBinaryEntrypointFilename, pyTestEntrypointFilename: return true default: return false } } func ensureNoCollision(file *rule.File, targetName, kind string) error { if file == nil { return nil } for _, t := range file.Rules { if t.Name() == targetName && t.Kind() != kind { return fmt.Errorf("a target of kind %q with the same name already exists", t.Kind()) } } return nil }