xref: /aosp_15_r20/external/bazelbuild-rules_python/gazelle/python/generate.go (revision 60517a1edbc8ecf509223e9af94a7adec7d736b8)
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