• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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