blob: c563b47bf3a44ce1fb0123c7af6c7a411260fd5c [file] [log] [blame]
// 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
}