1// Copyright 2023 The Bazel Authors. All rights reserved. 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15package python 16 17import ( 18 "fmt" 19 "io/fs" 20 "log" 21 "os" 22 "path/filepath" 23 "strings" 24 25 "github.com/bazelbuild/bazel-gazelle/config" 26 "github.com/bazelbuild/bazel-gazelle/label" 27 "github.com/bazelbuild/bazel-gazelle/language" 28 "github.com/bazelbuild/bazel-gazelle/rule" 29 "github.com/bazelbuild/rules_python/gazelle/pythonconfig" 30 "github.com/bmatcuk/doublestar" 31 "github.com/emirpasic/gods/lists/singlylinkedlist" 32 "github.com/emirpasic/gods/sets/treeset" 33 godsutils "github.com/emirpasic/gods/utils" 34) 35 36const ( 37 pyLibraryEntrypointFilename = "__init__.py" 38 pyBinaryEntrypointFilename = "__main__.py" 39 pyTestEntrypointFilename = "__test__.py" 40 pyTestEntrypointTargetname = "__test__" 41 conftestFilename = "conftest.py" 42 conftestTargetname = "conftest" 43) 44 45var ( 46 buildFilenames = []string{"BUILD", "BUILD.bazel"} 47) 48 49func GetActualKindName(kind string, args language.GenerateArgs) string { 50 if kindOverride, ok := args.Config.KindMap[kind]; ok { 51 return kindOverride.KindName 52 } 53 return kind 54} 55 56// GenerateRules extracts build metadata from source files in a directory. 57// GenerateRules is called in each directory where an update is requested 58// in depth-first post-order. 59func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateResult { 60 cfgs := args.Config.Exts[languageName].(pythonconfig.Configs) 61 cfg := cfgs[args.Rel] 62 63 if !cfg.ExtensionEnabled() { 64 return language.GenerateResult{} 65 } 66 67 if !isBazelPackage(args.Dir) { 68 if cfg.CoarseGrainedGeneration() { 69 // Determine if the current directory is the root of the coarse-grained 70 // generation. If not, return without generating anything. 71 parent := cfg.Parent() 72 if parent != nil && parent.CoarseGrainedGeneration() { 73 return language.GenerateResult{} 74 } 75 } else if !hasEntrypointFile(args.Dir) { 76 return language.GenerateResult{} 77 } 78 } 79 80 actualPyBinaryKind := GetActualKindName(pyBinaryKind, args) 81 actualPyLibraryKind := GetActualKindName(pyLibraryKind, args) 82 actualPyTestKind := GetActualKindName(pyTestKind, args) 83 84 pythonProjectRoot := cfg.PythonProjectRoot() 85 86 packageName := filepath.Base(args.Dir) 87 88 pyLibraryFilenames := treeset.NewWith(godsutils.StringComparator) 89 pyTestFilenames := treeset.NewWith(godsutils.StringComparator) 90 pyFileNames := treeset.NewWith(godsutils.StringComparator) 91 92 // hasPyBinary controls whether a py_binary target should be generated for 93 // this package or not. 94 hasPyBinary := false 95 96 // hasPyTestEntryPointFile and hasPyTestEntryPointTarget control whether a py_test target should 97 // be generated for this package or not. 98 hasPyTestEntryPointFile := false 99 hasPyTestEntryPointTarget := false 100 hasConftestFile := false 101 102 for _, f := range args.RegularFiles { 103 if cfg.IgnoresFile(filepath.Base(f)) { 104 continue 105 } 106 ext := filepath.Ext(f) 107 if ext == ".py" { 108 pyFileNames.Add(f) 109 if !hasPyBinary && f == pyBinaryEntrypointFilename { 110 hasPyBinary = true 111 } else if !hasPyTestEntryPointFile && f == pyTestEntrypointFilename { 112 hasPyTestEntryPointFile = true 113 } else if f == conftestFilename { 114 hasConftestFile = true 115 } else if strings.HasSuffix(f, "_test.py") || strings.HasPrefix(f, "test_") { 116 pyTestFilenames.Add(f) 117 } else { 118 pyLibraryFilenames.Add(f) 119 } 120 } 121 } 122 123 // If a __test__.py file was not found on disk, search for targets that are 124 // named __test__. 125 if !hasPyTestEntryPointFile && args.File != nil { 126 for _, rule := range args.File.Rules { 127 if rule.Name() == pyTestEntrypointTargetname { 128 hasPyTestEntryPointTarget = true 129 break 130 } 131 } 132 } 133 134 // Add files from subdirectories if they meet the criteria. 135 for _, d := range args.Subdirs { 136 // boundaryPackages represents child Bazel packages that are used as a 137 // boundary to stop processing under that tree. 138 boundaryPackages := make(map[string]struct{}) 139 err := filepath.WalkDir( 140 filepath.Join(args.Dir, d), 141 func(path string, entry fs.DirEntry, err error) error { 142 if err != nil { 143 return err 144 } 145 // Ignore the path if it crosses any boundary package. Walking 146 // the tree is still important because subsequent paths can 147 // represent files that have not crossed any boundaries. 148 for bp := range boundaryPackages { 149 if strings.HasPrefix(path, bp) { 150 return nil 151 } 152 } 153 if entry.IsDir() { 154 // If we are visiting a directory, we determine if we should 155 // halt digging the tree based on a few criterias: 156 // 1. The directory has a BUILD or BUILD.bazel files. Then 157 // it doesn't matter at all what it has since it's a 158 // separate Bazel package. 159 // 2. (only for fine-grained generation) The directory has 160 // an __init__.py, __main__.py or __test__.py, meaning 161 // a BUILD file will be generated. 162 if isBazelPackage(path) { 163 boundaryPackages[path] = struct{}{} 164 return nil 165 } 166 167 if !cfg.CoarseGrainedGeneration() && hasEntrypointFile(path) { 168 return fs.SkipDir 169 } 170 171 return nil 172 } 173 if filepath.Ext(path) == ".py" { 174 if cfg.CoarseGrainedGeneration() || !isEntrypointFile(path) { 175 srcPath, _ := filepath.Rel(args.Dir, path) 176 repoPath := filepath.Join(args.Rel, srcPath) 177 excludedPatterns := cfg.ExcludedPatterns() 178 if excludedPatterns != nil { 179 it := excludedPatterns.Iterator() 180 for it.Next() { 181 excludedPattern := it.Value().(string) 182 isExcluded, err := doublestar.Match(excludedPattern, repoPath) 183 if err != nil { 184 return err 185 } 186 if isExcluded { 187 return nil 188 } 189 } 190 } 191 baseName := filepath.Base(path) 192 if strings.HasSuffix(baseName, "_test.py") || strings.HasPrefix(baseName, "test_") { 193 pyTestFilenames.Add(srcPath) 194 } else { 195 pyLibraryFilenames.Add(srcPath) 196 } 197 } 198 } 199 return nil 200 }, 201 ) 202 if err != nil { 203 log.Printf("ERROR: %v\n", err) 204 return language.GenerateResult{} 205 } 206 } 207 208 parser := newPython3Parser(args.Config.RepoRoot, args.Rel, cfg.IgnoresDependency) 209 visibility := fmt.Sprintf("//%s:__subpackages__", pythonProjectRoot) 210 211 var result language.GenerateResult 212 result.Gen = make([]*rule.Rule, 0) 213 214 collisionErrors := singlylinkedlist.New() 215 216 var pyLibrary *rule.Rule 217 if !pyLibraryFilenames.Empty() { 218 deps, err := parser.parse(pyLibraryFilenames) 219 if err != nil { 220 log.Fatalf("ERROR: %v\n", err) 221 } 222 223 pyLibraryTargetName := cfg.RenderLibraryName(packageName) 224 225 // Check if a target with the same name we are generating already 226 // exists, and if it is of a different kind from the one we are 227 // generating. If so, we have to throw an error since Gazelle won't 228 // generate it correctly. 229 if args.File != nil { 230 for _, t := range args.File.Rules { 231 if t.Name() == pyLibraryTargetName && t.Kind() != actualPyLibraryKind { 232 fqTarget := label.New("", args.Rel, pyLibraryTargetName) 233 err := fmt.Errorf("failed to generate target %q of kind %q: "+ 234 "a target of kind %q with the same name already exists. "+ 235 "Use the '# gazelle:%s' directive to change the naming convention.", 236 fqTarget.String(), actualPyLibraryKind, t.Kind(), pythonconfig.LibraryNamingConvention) 237 collisionErrors.Add(err) 238 } 239 } 240 } 241 242 pyLibrary = newTargetBuilder(pyLibraryKind, pyLibraryTargetName, pythonProjectRoot, args.Rel, pyFileNames). 243 addVisibility(visibility). 244 addSrcs(pyLibraryFilenames). 245 addModuleDependencies(deps). 246 generateImportsAttribute(). 247 build() 248 249 result.Gen = append(result.Gen, pyLibrary) 250 result.Imports = append(result.Imports, pyLibrary.PrivateAttr(config.GazelleImportsKey)) 251 } 252 253 if hasPyBinary { 254 deps, err := parser.parseSingle(pyBinaryEntrypointFilename) 255 if err != nil { 256 log.Fatalf("ERROR: %v\n", err) 257 } 258 259 pyBinaryTargetName := cfg.RenderBinaryName(packageName) 260 261 // Check if a target with the same name we are generating already 262 // exists, and if it is of a different kind from the one we are 263 // generating. If so, we have to throw an error since Gazelle won't 264 // generate it correctly. 265 if args.File != nil { 266 for _, t := range args.File.Rules { 267 if t.Name() == pyBinaryTargetName && t.Kind() != actualPyBinaryKind { 268 fqTarget := label.New("", args.Rel, pyBinaryTargetName) 269 err := fmt.Errorf("failed to generate target %q of kind %q: "+ 270 "a target of kind %q with the same name already exists. "+ 271 "Use the '# gazelle:%s' directive to change the naming convention.", 272 fqTarget.String(), actualPyBinaryKind, t.Kind(), pythonconfig.BinaryNamingConvention) 273 collisionErrors.Add(err) 274 } 275 } 276 } 277 278 pyBinaryTarget := newTargetBuilder(pyBinaryKind, pyBinaryTargetName, pythonProjectRoot, args.Rel, pyFileNames). 279 setMain(pyBinaryEntrypointFilename). 280 addVisibility(visibility). 281 addSrc(pyBinaryEntrypointFilename). 282 addModuleDependencies(deps). 283 generateImportsAttribute() 284 285 pyBinary := pyBinaryTarget.build() 286 287 result.Gen = append(result.Gen, pyBinary) 288 result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey)) 289 } 290 291 var conftest *rule.Rule 292 if hasConftestFile { 293 deps, err := parser.parseSingle(conftestFilename) 294 if err != nil { 295 log.Fatalf("ERROR: %v\n", err) 296 } 297 298 // Check if a target with the same name we are generating already 299 // exists, and if it is of a different kind from the one we are 300 // generating. If so, we have to throw an error since Gazelle won't 301 // generate it correctly. 302 if args.File != nil { 303 for _, t := range args.File.Rules { 304 if t.Name() == conftestTargetname && t.Kind() != actualPyLibraryKind { 305 fqTarget := label.New("", args.Rel, conftestTargetname) 306 err := fmt.Errorf("failed to generate target %q of kind %q: "+ 307 "a target of kind %q with the same name already exists.", 308 fqTarget.String(), actualPyLibraryKind, t.Kind()) 309 collisionErrors.Add(err) 310 } 311 } 312 } 313 314 conftestTarget := newTargetBuilder(pyLibraryKind, conftestTargetname, pythonProjectRoot, args.Rel, pyFileNames). 315 addSrc(conftestFilename). 316 addModuleDependencies(deps). 317 addVisibility(visibility). 318 setTestonly(). 319 generateImportsAttribute() 320 321 conftest = conftestTarget.build() 322 323 result.Gen = append(result.Gen, conftest) 324 result.Imports = append(result.Imports, conftest.PrivateAttr(config.GazelleImportsKey)) 325 } 326 327 var pyTestTargets []*targetBuilder 328 newPyTestTargetBuilder := func(srcs *treeset.Set, pyTestTargetName string) *targetBuilder { 329 deps, err := parser.parse(srcs) 330 if err != nil { 331 log.Fatalf("ERROR: %v\n", err) 332 } 333 // Check if a target with the same name we are generating already 334 // exists, and if it is of a different kind from the one we are 335 // generating. If so, we have to throw an error since Gazelle won't 336 // generate it correctly. 337 if args.File != nil { 338 for _, t := range args.File.Rules { 339 if t.Name() == pyTestTargetName && t.Kind() != actualPyTestKind { 340 fqTarget := label.New("", args.Rel, pyTestTargetName) 341 err := fmt.Errorf("failed to generate target %q of kind %q: "+ 342 "a target of kind %q with the same name already exists. "+ 343 "Use the '# gazelle:%s' directive to change the naming convention.", 344 fqTarget.String(), actualPyTestKind, t.Kind(), pythonconfig.TestNamingConvention) 345 collisionErrors.Add(err) 346 } 347 } 348 } 349 return newTargetBuilder(pyTestKind, pyTestTargetName, pythonProjectRoot, args.Rel, pyFileNames). 350 addSrcs(srcs). 351 addModuleDependencies(deps). 352 generateImportsAttribute() 353 } 354 if hasPyTestEntryPointFile || hasPyTestEntryPointTarget { 355 if hasPyTestEntryPointFile { 356 // Only add the pyTestEntrypointFilename to the pyTestFilenames if 357 // the file exists on disk. 358 pyTestFilenames.Add(pyTestEntrypointFilename) 359 } 360 pyTestTargetName := cfg.RenderTestName(packageName) 361 pyTestTarget := newPyTestTargetBuilder(pyTestFilenames, pyTestTargetName) 362 363 if hasPyTestEntryPointTarget { 364 entrypointTarget := fmt.Sprintf(":%s", pyTestEntrypointTargetname) 365 main := fmt.Sprintf(":%s", pyTestEntrypointFilename) 366 pyTestTarget. 367 addSrc(entrypointTarget). 368 addResolvedDependency(entrypointTarget). 369 setMain(main) 370 } else { 371 pyTestTarget.setMain(pyTestEntrypointFilename) 372 } 373 pyTestTargets = append(pyTestTargets, pyTestTarget) 374 } else { 375 // Create one py_test target per file 376 pyTestFilenames.Each(func(index int, testFile interface{}) { 377 srcs := treeset.NewWith(godsutils.StringComparator, testFile) 378 pyTestTargetName := strings.TrimSuffix(filepath.Base(testFile.(string)), ".py") 379 pyTestTargets = append(pyTestTargets, newPyTestTargetBuilder(srcs, pyTestTargetName)) 380 }) 381 } 382 383 for _, pyTestTarget := range pyTestTargets { 384 if conftest != nil { 385 pyTestTarget.addModuleDependency(module{Name: strings.TrimSuffix(conftestFilename, ".py")}) 386 } 387 pyTest := pyTestTarget.build() 388 389 result.Gen = append(result.Gen, pyTest) 390 result.Imports = append(result.Imports, pyTest.PrivateAttr(config.GazelleImportsKey)) 391 } 392 393 if !collisionErrors.Empty() { 394 it := collisionErrors.Iterator() 395 for it.Next() { 396 log.Printf("ERROR: %v\n", it.Value()) 397 } 398 os.Exit(1) 399 } 400 401 return result 402} 403 404// isBazelPackage determines if the directory is a Bazel package by probing for 405// the existence of a known BUILD file name. 406func isBazelPackage(dir string) bool { 407 for _, buildFilename := range buildFilenames { 408 path := filepath.Join(dir, buildFilename) 409 if _, err := os.Stat(path); err == nil { 410 return true 411 } 412 } 413 return false 414} 415 416// hasEntrypointFile determines if the directory has any of the established 417// entrypoint filenames. 418func hasEntrypointFile(dir string) bool { 419 for _, entrypointFilename := range []string{ 420 pyLibraryEntrypointFilename, 421 pyBinaryEntrypointFilename, 422 pyTestEntrypointFilename, 423 } { 424 path := filepath.Join(dir, entrypointFilename) 425 if _, err := os.Stat(path); err == nil { 426 return true 427 } 428 } 429 return false 430} 431 432// isEntrypointFile returns whether the given path is an entrypoint file. The 433// given path can be absolute or relative. 434func isEntrypointFile(path string) bool { 435 basePath := filepath.Base(path) 436 switch basePath { 437 case pyLibraryEntrypointFilename, 438 pyBinaryEntrypointFilename, 439 pyTestEntrypointFilename: 440 return true 441 default: 442 return false 443 } 444} 445