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 "sort" 24 "strings" 25 26 "github.com/bazelbuild/bazel-gazelle/config" 27 "github.com/bazelbuild/bazel-gazelle/label" 28 "github.com/bazelbuild/bazel-gazelle/language" 29 "github.com/bazelbuild/bazel-gazelle/rule" 30 "github.com/bmatcuk/doublestar/v4" 31 "github.com/emirpasic/gods/lists/singlylinkedlist" 32 "github.com/emirpasic/gods/sets/treeset" 33 godsutils "github.com/emirpasic/gods/utils" 34 35 "github.com/bazelbuild/rules_python/gazelle/pythonconfig" 36) 37 38const ( 39 pyLibraryEntrypointFilename = "__init__.py" 40 pyBinaryEntrypointFilename = "__main__.py" 41 pyTestEntrypointFilename = "__test__.py" 42 pyTestEntrypointTargetname = "__test__" 43 conftestFilename = "conftest.py" 44 conftestTargetname = "conftest" 45) 46 47var ( 48 buildFilenames = []string{"BUILD", "BUILD.bazel"} 49) 50 51func GetActualKindName(kind string, args language.GenerateArgs) string { 52 if kindOverride, ok := args.Config.KindMap[kind]; ok { 53 return kindOverride.KindName 54 } 55 return kind 56} 57 58func matchesAnyGlob(s string, globs []string) bool { 59 // This function assumes that the globs have already been validated. If a glob is 60 // invalid, it's considered a non-match and we move on to the next pattern. 61 for _, g := range globs { 62 if ok, _ := doublestar.Match(g, s); ok { 63 return true 64 } 65 } 66 return false 67} 68 69// GenerateRules extracts build metadata from source files in a directory. 70// GenerateRules is called in each directory where an update is requested 71// in depth-first post-order. 72func (py *Python) GenerateRules(args language.GenerateArgs) language.GenerateResult { 73 cfgs := args.Config.Exts[languageName].(pythonconfig.Configs) 74 cfg := cfgs[args.Rel] 75 76 if !cfg.ExtensionEnabled() { 77 return language.GenerateResult{} 78 } 79 80 if !isBazelPackage(args.Dir) { 81 if cfg.CoarseGrainedGeneration() { 82 // Determine if the current directory is the root of the coarse-grained 83 // generation. If not, return without generating anything. 84 parent := cfg.Parent() 85 if parent != nil && parent.CoarseGrainedGeneration() { 86 return language.GenerateResult{} 87 } 88 } else if !hasEntrypointFile(args.Dir) { 89 return language.GenerateResult{} 90 } 91 } 92 93 actualPyBinaryKind := GetActualKindName(pyBinaryKind, args) 94 actualPyLibraryKind := GetActualKindName(pyLibraryKind, args) 95 actualPyTestKind := GetActualKindName(pyTestKind, args) 96 97 pythonProjectRoot := cfg.PythonProjectRoot() 98 99 packageName := filepath.Base(args.Dir) 100 101 pyLibraryFilenames := treeset.NewWith(godsutils.StringComparator) 102 pyTestFilenames := treeset.NewWith(godsutils.StringComparator) 103 pyFileNames := treeset.NewWith(godsutils.StringComparator) 104 105 // hasPyBinaryEntryPointFile controls whether a single py_binary target should be generated for 106 // this package or not. 107 hasPyBinaryEntryPointFile := false 108 109 // hasPyTestEntryPointFile and hasPyTestEntryPointTarget control whether a py_test target should 110 // be generated for this package or not. 111 hasPyTestEntryPointFile := false 112 hasPyTestEntryPointTarget := false 113 hasConftestFile := false 114 115 testFileGlobs := cfg.TestFilePattern() 116 117 for _, f := range args.RegularFiles { 118 if cfg.IgnoresFile(filepath.Base(f)) { 119 continue 120 } 121 ext := filepath.Ext(f) 122 if ext == ".py" { 123 pyFileNames.Add(f) 124 if !hasPyBinaryEntryPointFile && f == pyBinaryEntrypointFilename { 125 hasPyBinaryEntryPointFile = true 126 } else if !hasPyTestEntryPointFile && f == pyTestEntrypointFilename { 127 hasPyTestEntryPointFile = true 128 } else if f == conftestFilename { 129 hasConftestFile = true 130 } else if matchesAnyGlob(f, testFileGlobs) { 131 pyTestFilenames.Add(f) 132 } else { 133 pyLibraryFilenames.Add(f) 134 } 135 } 136 } 137 138 // If a __test__.py file was not found on disk, search for targets that are 139 // named __test__. 140 if !hasPyTestEntryPointFile && args.File != nil { 141 for _, rule := range args.File.Rules { 142 if rule.Name() == pyTestEntrypointTargetname { 143 hasPyTestEntryPointTarget = true 144 break 145 } 146 } 147 } 148 149 // Add files from subdirectories if they meet the criteria. 150 for _, d := range args.Subdirs { 151 // boundaryPackages represents child Bazel packages that are used as a 152 // boundary to stop processing under that tree. 153 boundaryPackages := make(map[string]struct{}) 154 err := filepath.WalkDir( 155 filepath.Join(args.Dir, d), 156 func(path string, entry fs.DirEntry, err error) error { 157 if err != nil { 158 return err 159 } 160 // Ignore the path if it crosses any boundary package. Walking 161 // the tree is still important because subsequent paths can 162 // represent files that have not crossed any boundaries. 163 for bp := range boundaryPackages { 164 if strings.HasPrefix(path, bp) { 165 return nil 166 } 167 } 168 if entry.IsDir() { 169 // If we are visiting a directory, we determine if we should 170 // halt digging the tree based on a few criterias: 171 // 1. We are using per-file generation. 172 // 2. The directory has a BUILD or BUILD.bazel files. Then 173 // it doesn't matter at all what it has since it's a 174 // separate Bazel package. 175 // 3. (only for package generation) The directory has an 176 // __init__.py, __main__.py or __test__.py, meaning a 177 // BUILD file will be generated. 178 if cfg.PerFileGeneration() { 179 return fs.SkipDir 180 } 181 182 if isBazelPackage(path) { 183 boundaryPackages[path] = struct{}{} 184 return nil 185 } 186 187 if !cfg.CoarseGrainedGeneration() && hasEntrypointFile(path) { 188 return fs.SkipDir 189 } 190 191 return nil 192 } 193 if filepath.Ext(path) == ".py" { 194 if cfg.CoarseGrainedGeneration() || !isEntrypointFile(path) { 195 srcPath, _ := filepath.Rel(args.Dir, path) 196 repoPath := filepath.Join(args.Rel, srcPath) 197 excludedPatterns := cfg.ExcludedPatterns() 198 if excludedPatterns != nil { 199 it := excludedPatterns.Iterator() 200 for it.Next() { 201 excludedPattern := it.Value().(string) 202 isExcluded, err := doublestar.Match(excludedPattern, repoPath) 203 if err != nil { 204 return err 205 } 206 if isExcluded { 207 return nil 208 } 209 } 210 } 211 baseName := filepath.Base(path) 212 if matchesAnyGlob(baseName, testFileGlobs) { 213 pyTestFilenames.Add(srcPath) 214 } else { 215 pyLibraryFilenames.Add(srcPath) 216 } 217 } 218 } 219 return nil 220 }, 221 ) 222 if err != nil { 223 log.Printf("ERROR: %v\n", err) 224 return language.GenerateResult{} 225 } 226 } 227 228 parser := newPython3Parser(args.Config.RepoRoot, args.Rel, cfg.IgnoresDependency) 229 visibility := cfg.Visibility() 230 231 var result language.GenerateResult 232 result.Gen = make([]*rule.Rule, 0) 233 234 collisionErrors := singlylinkedlist.New() 235 236 appendPyLibrary := func(srcs *treeset.Set, pyLibraryTargetName string) { 237 allDeps, mainModules, annotations, err := parser.parse(srcs) 238 if err != nil { 239 log.Fatalf("ERROR: %v\n", err) 240 } 241 242 if !hasPyBinaryEntryPointFile { 243 // Creating one py_binary target per main module when __main__.py doesn't exist. 244 mainFileNames := make([]string, 0, len(mainModules)) 245 for name := range mainModules { 246 mainFileNames = append(mainFileNames, name) 247 248 // Remove the file from srcs if we're doing per-file library generation so 249 // that we don't also generate a py_library target for it. 250 if cfg.PerFileGeneration() { 251 srcs.Remove(name) 252 } 253 } 254 sort.Strings(mainFileNames) 255 for _, filename := range mainFileNames { 256 pyBinaryTargetName := strings.TrimSuffix(filepath.Base(filename), ".py") 257 if err := ensureNoCollision(args.File, pyBinaryTargetName, actualPyBinaryKind); err != nil { 258 fqTarget := label.New("", args.Rel, pyBinaryTargetName) 259 log.Printf("failed to generate target %q of kind %q: %v", 260 fqTarget.String(), actualPyBinaryKind, err) 261 continue 262 } 263 pyBinary := newTargetBuilder(pyBinaryKind, pyBinaryTargetName, pythonProjectRoot, args.Rel, pyFileNames). 264 addVisibility(visibility). 265 addSrc(filename). 266 addModuleDependencies(mainModules[filename]). 267 addResolvedDependencies(annotations.includeDeps). 268 generateImportsAttribute().build() 269 result.Gen = append(result.Gen, pyBinary) 270 result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey)) 271 } 272 } 273 274 // If we're doing per-file generation, srcs could be empty at this point, meaning we shouldn't make a py_library. 275 // If there is already a package named py_library target before, we should generate an empty py_library. 276 if srcs.Empty() { 277 if args.File == nil { 278 return 279 } 280 generateEmptyLibrary := false 281 for _, r := range args.File.Rules { 282 if r.Kind() == actualPyLibraryKind && r.Name() == pyLibraryTargetName { 283 generateEmptyLibrary = true 284 } 285 } 286 if !generateEmptyLibrary { 287 return 288 } 289 } 290 291 // Check if a target with the same name we are generating already 292 // exists, and if it is of a different kind from the one we are 293 // generating. If so, we have to throw an error since Gazelle won't 294 // generate it correctly. 295 if err := ensureNoCollision(args.File, pyLibraryTargetName, actualPyLibraryKind); err != nil { 296 fqTarget := label.New("", args.Rel, pyLibraryTargetName) 297 err := fmt.Errorf("failed to generate target %q of kind %q: %w. "+ 298 "Use the '# gazelle:%s' directive to change the naming convention.", 299 fqTarget.String(), actualPyLibraryKind, err, pythonconfig.LibraryNamingConvention) 300 collisionErrors.Add(err) 301 } 302 303 pyLibrary := newTargetBuilder(pyLibraryKind, pyLibraryTargetName, pythonProjectRoot, args.Rel, pyFileNames). 304 addVisibility(visibility). 305 addSrcs(srcs). 306 addModuleDependencies(allDeps). 307 addResolvedDependencies(annotations.includeDeps). 308 generateImportsAttribute(). 309 build() 310 311 if pyLibrary.IsEmpty(py.Kinds()[pyLibrary.Kind()]) { 312 result.Empty = append(result.Gen, pyLibrary) 313 } else { 314 result.Gen = append(result.Gen, pyLibrary) 315 result.Imports = append(result.Imports, pyLibrary.PrivateAttr(config.GazelleImportsKey)) 316 } 317 } 318 if cfg.PerFileGeneration() { 319 hasInit, nonEmptyInit := hasLibraryEntrypointFile(args.Dir) 320 pyLibraryFilenames.Each(func(index int, filename interface{}) { 321 pyLibraryTargetName := strings.TrimSuffix(filepath.Base(filename.(string)), ".py") 322 if filename == pyLibraryEntrypointFilename && !nonEmptyInit { 323 return // ignore empty __init__.py. 324 } 325 srcs := treeset.NewWith(godsutils.StringComparator, filename) 326 if cfg.PerFileGenerationIncludeInit() && hasInit && nonEmptyInit { 327 srcs.Add(pyLibraryEntrypointFilename) 328 } 329 appendPyLibrary(srcs, pyLibraryTargetName) 330 }) 331 } else { 332 appendPyLibrary(pyLibraryFilenames, cfg.RenderLibraryName(packageName)) 333 } 334 335 if hasPyBinaryEntryPointFile { 336 deps, _, annotations, err := parser.parseSingle(pyBinaryEntrypointFilename) 337 if err != nil { 338 log.Fatalf("ERROR: %v\n", err) 339 } 340 341 pyBinaryTargetName := cfg.RenderBinaryName(packageName) 342 343 // Check if a target with the same name we are generating already 344 // exists, and if it is of a different kind from the one we are 345 // generating. If so, we have to throw an error since Gazelle won't 346 // generate it correctly. 347 if err := ensureNoCollision(args.File, pyBinaryTargetName, actualPyBinaryKind); err != nil { 348 fqTarget := label.New("", args.Rel, pyBinaryTargetName) 349 err := fmt.Errorf("failed to generate target %q of kind %q: %w. "+ 350 "Use the '# gazelle:%s' directive to change the naming convention.", 351 fqTarget.String(), actualPyBinaryKind, err, pythonconfig.BinaryNamingConvention) 352 collisionErrors.Add(err) 353 } 354 355 pyBinaryTarget := newTargetBuilder(pyBinaryKind, pyBinaryTargetName, pythonProjectRoot, args.Rel, pyFileNames). 356 setMain(pyBinaryEntrypointFilename). 357 addVisibility(visibility). 358 addSrc(pyBinaryEntrypointFilename). 359 addModuleDependencies(deps). 360 addResolvedDependencies(annotations.includeDeps). 361 generateImportsAttribute() 362 363 pyBinary := pyBinaryTarget.build() 364 365 result.Gen = append(result.Gen, pyBinary) 366 result.Imports = append(result.Imports, pyBinary.PrivateAttr(config.GazelleImportsKey)) 367 } 368 369 var conftest *rule.Rule 370 if hasConftestFile { 371 deps, _, annotations, err := parser.parseSingle(conftestFilename) 372 if err != nil { 373 log.Fatalf("ERROR: %v\n", err) 374 } 375 376 // Check if a target with the same name we are generating already 377 // exists, and if it is of a different kind from the one we are 378 // generating. If so, we have to throw an error since Gazelle won't 379 // generate it correctly. 380 if err := ensureNoCollision(args.File, conftestTargetname, actualPyLibraryKind); err != nil { 381 fqTarget := label.New("", args.Rel, conftestTargetname) 382 err := fmt.Errorf("failed to generate target %q of kind %q: %w. ", 383 fqTarget.String(), actualPyLibraryKind, err) 384 collisionErrors.Add(err) 385 } 386 387 conftestTarget := newTargetBuilder(pyLibraryKind, conftestTargetname, pythonProjectRoot, args.Rel, pyFileNames). 388 addSrc(conftestFilename). 389 addModuleDependencies(deps). 390 addResolvedDependencies(annotations.includeDeps). 391 addVisibility(visibility). 392 setTestonly(). 393 generateImportsAttribute() 394 395 conftest = conftestTarget.build() 396 397 result.Gen = append(result.Gen, conftest) 398 result.Imports = append(result.Imports, conftest.PrivateAttr(config.GazelleImportsKey)) 399 } 400 401 var pyTestTargets []*targetBuilder 402 newPyTestTargetBuilder := func(srcs *treeset.Set, pyTestTargetName string) *targetBuilder { 403 deps, _, annotations, err := parser.parse(srcs) 404 if err != nil { 405 log.Fatalf("ERROR: %v\n", err) 406 } 407 // Check if a target with the same name we are generating already 408 // exists, and if it is of a different kind from the one we are 409 // generating. If so, we have to throw an error since Gazelle won't 410 // generate it correctly. 411 if err := ensureNoCollision(args.File, pyTestTargetName, actualPyTestKind); err != nil { 412 fqTarget := label.New("", args.Rel, pyTestTargetName) 413 err := fmt.Errorf("failed to generate target %q of kind %q: %w. "+ 414 "Use the '# gazelle:%s' directive to change the naming convention.", 415 fqTarget.String(), actualPyTestKind, err, pythonconfig.TestNamingConvention) 416 collisionErrors.Add(err) 417 } 418 return newTargetBuilder(pyTestKind, pyTestTargetName, pythonProjectRoot, args.Rel, pyFileNames). 419 addSrcs(srcs). 420 addModuleDependencies(deps). 421 addResolvedDependencies(annotations.includeDeps). 422 generateImportsAttribute() 423 } 424 if (!cfg.PerPackageGenerationRequireTestEntryPoint() || hasPyTestEntryPointFile || hasPyTestEntryPointTarget || cfg.CoarseGrainedGeneration()) && !cfg.PerFileGeneration() { 425 // Create one py_test target per package 426 if hasPyTestEntryPointFile { 427 // Only add the pyTestEntrypointFilename to the pyTestFilenames if 428 // the file exists on disk. 429 pyTestFilenames.Add(pyTestEntrypointFilename) 430 } 431 if hasPyTestEntryPointTarget || !pyTestFilenames.Empty() { 432 pyTestTargetName := cfg.RenderTestName(packageName) 433 pyTestTarget := newPyTestTargetBuilder(pyTestFilenames, pyTestTargetName) 434 435 if hasPyTestEntryPointTarget { 436 entrypointTarget := fmt.Sprintf(":%s", pyTestEntrypointTargetname) 437 main := fmt.Sprintf(":%s", pyTestEntrypointFilename) 438 pyTestTarget. 439 addSrc(entrypointTarget). 440 addResolvedDependency(entrypointTarget). 441 setMain(main) 442 } else if hasPyTestEntryPointFile { 443 pyTestTarget.setMain(pyTestEntrypointFilename) 444 } /* else: 445 main is not set, assuming there is a test file with the same name 446 as the target name, or there is a macro wrapping py_test and setting its main attribute. 447 */ 448 pyTestTargets = append(pyTestTargets, pyTestTarget) 449 } 450 } else { 451 // Create one py_test target per file 452 pyTestFilenames.Each(func(index int, testFile interface{}) { 453 srcs := treeset.NewWith(godsutils.StringComparator, testFile) 454 pyTestTargetName := strings.TrimSuffix(filepath.Base(testFile.(string)), ".py") 455 pyTestTarget := newPyTestTargetBuilder(srcs, pyTestTargetName) 456 457 if hasPyTestEntryPointTarget { 458 entrypointTarget := fmt.Sprintf(":%s", pyTestEntrypointTargetname) 459 main := fmt.Sprintf(":%s", pyTestEntrypointFilename) 460 pyTestTarget. 461 addSrc(entrypointTarget). 462 addResolvedDependency(entrypointTarget). 463 setMain(main) 464 } else if hasPyTestEntryPointFile { 465 pyTestTarget.addSrc(pyTestEntrypointFilename) 466 pyTestTarget.setMain(pyTestEntrypointFilename) 467 } 468 pyTestTargets = append(pyTestTargets, pyTestTarget) 469 }) 470 } 471 472 for _, pyTestTarget := range pyTestTargets { 473 if conftest != nil { 474 pyTestTarget.addModuleDependency(module{Name: strings.TrimSuffix(conftestFilename, ".py")}) 475 } 476 pyTest := pyTestTarget.build() 477 478 result.Gen = append(result.Gen, pyTest) 479 result.Imports = append(result.Imports, pyTest.PrivateAttr(config.GazelleImportsKey)) 480 } 481 482 if !collisionErrors.Empty() { 483 it := collisionErrors.Iterator() 484 for it.Next() { 485 log.Printf("ERROR: %v\n", it.Value()) 486 } 487 os.Exit(1) 488 } 489 490 return result 491} 492 493// isBazelPackage determines if the directory is a Bazel package by probing for 494// the existence of a known BUILD file name. 495func isBazelPackage(dir string) bool { 496 for _, buildFilename := range buildFilenames { 497 path := filepath.Join(dir, buildFilename) 498 if _, err := os.Stat(path); err == nil { 499 return true 500 } 501 } 502 return false 503} 504 505// hasEntrypointFile determines if the directory has any of the established 506// entrypoint filenames. 507func hasEntrypointFile(dir string) bool { 508 for _, entrypointFilename := range []string{ 509 pyLibraryEntrypointFilename, 510 pyBinaryEntrypointFilename, 511 pyTestEntrypointFilename, 512 } { 513 path := filepath.Join(dir, entrypointFilename) 514 if _, err := os.Stat(path); err == nil { 515 return true 516 } 517 } 518 return false 519} 520 521// hasLibraryEntrypointFile returns if the given directory has the library 522// entrypoint file, and if it is non-empty. 523func hasLibraryEntrypointFile(dir string) (bool, bool) { 524 stat, err := os.Stat(filepath.Join(dir, pyLibraryEntrypointFilename)) 525 if os.IsNotExist(err) { 526 return false, false 527 } 528 if err != nil { 529 log.Fatalf("ERROR: %v\n", err) 530 } 531 return true, stat.Size() != 0 532} 533 534// isEntrypointFile returns whether the given path is an entrypoint file. The 535// given path can be absolute or relative. 536func isEntrypointFile(path string) bool { 537 basePath := filepath.Base(path) 538 switch basePath { 539 case pyLibraryEntrypointFilename, 540 pyBinaryEntrypointFilename, 541 pyTestEntrypointFilename: 542 return true 543 default: 544 return false 545 } 546} 547 548func ensureNoCollision(file *rule.File, targetName, kind string) error { 549 if file == nil { 550 return nil 551 } 552 for _, t := range file.Rules { 553 if t.Name() == targetName && t.Kind() != kind { 554 return fmt.Errorf("a target of kind %q with the same name already exists", t.Kind()) 555 } 556 } 557 return nil 558} 559