Skip to content

Commit 566f562

Browse files
authored
Detect setuptools/hatch/flit and infer flat-layout source dirs (#69)
1 parent 3803e7c commit 566f562

7 files changed

Lines changed: 296 additions & 4 deletions

File tree

detect/detect.go

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func (e *Engine) Run() (*brief.Report, error) {
165165
e.detectSelf(abs, report)
166166

167167
report.Style = e.detectStyle()
168-
report.Layout = e.detectLayout()
168+
report.Layout = e.detectLayout(report.Languages)
169169
report.Platforms = e.detectPlatforms()
170170

171171
// Run slow detections concurrently.
@@ -973,7 +973,7 @@ func (e *Engine) inferStyle() *brief.StyleInfo {
973973
}
974974

975975
// detectLayout checks for source and test directory patterns from the knowledge base.
976-
func (e *Engine) detectLayout() *brief.LayoutInfo {
976+
func (e *Engine) detectLayout(languages []brief.Detection) *brief.LayoutInfo {
977977
if e.KB.Layouts == nil {
978978
return nil
979979
}
@@ -992,13 +992,98 @@ func (e *Engine) detectLayout() *brief.LayoutInfo {
992992
}
993993
}
994994

995+
if len(layout.SourceDirs) == 0 {
996+
layout.SourceDirs = e.inferFlatLayout(languages, layout.TestDirs)
997+
}
998+
995999
if len(layout.SourceDirs) == 0 && len(layout.TestDirs) == 0 {
9961000
return nil
9971001
}
9981002

9991003
return layout
10001004
}
10011005

1006+
// inferFlatLayout finds top-level directories that hold source for the primary
1007+
// detected language when no conventional source directory (src/, lib/, etc.)
1008+
// exists. This covers projects where the package directory is named after the
1009+
// project rather than a generic name.
1010+
func (e *Engine) inferFlatLayout(languages []brief.Detection, testDirs []string) []string {
1011+
if len(languages) == 0 {
1012+
return nil
1013+
}
1014+
1015+
exts := e.languageExtensions(languages[0].Name)
1016+
if len(exts) == 0 {
1017+
return nil
1018+
}
1019+
1020+
skip := make(map[string]bool)
1021+
for _, d := range e.KB.Layouts.Layout.ExcludeDirs {
1022+
skip[d] = true
1023+
}
1024+
for _, d := range e.KB.Layouts.Layout.TestDirs {
1025+
skip[d] = true
1026+
}
1027+
for _, d := range testDirs {
1028+
skip[d] = true
1029+
}
1030+
1031+
entries, err := os.ReadDir(e.Root)
1032+
if err != nil {
1033+
return nil
1034+
}
1035+
1036+
var found []string
1037+
for _, ent := range entries {
1038+
if !ent.IsDir() {
1039+
continue
1040+
}
1041+
name := ent.Name()
1042+
if e.shouldSkipDir(name) || skip[name] {
1043+
continue
1044+
}
1045+
if e.dirHasExtension(name, exts) {
1046+
found = append(found, name)
1047+
}
1048+
}
1049+
return found
1050+
}
1051+
1052+
// languageExtensions returns the file extensions a language tool definition
1053+
// matches on, derived from its "*.ext" detection patterns.
1054+
func (e *Engine) languageExtensions(name string) []string {
1055+
tool := e.KB.ByName[name]
1056+
if tool == nil {
1057+
return nil
1058+
}
1059+
seen := make(map[string]bool)
1060+
var exts []string
1061+
for _, pattern := range tool.Detect.Files {
1062+
if idx := strings.LastIndex(pattern, "*."); idx >= 0 {
1063+
ext := pattern[idx+1:]
1064+
if !seen[ext] {
1065+
seen[ext] = true
1066+
exts = append(exts, ext)
1067+
}
1068+
}
1069+
}
1070+
return exts
1071+
}
1072+
1073+
// dirHasExtension reports whether dir directly contains a file with one of the
1074+
// given extensions.
1075+
func (e *Engine) dirHasExtension(dir string, exts []string) bool {
1076+
for _, name := range e.dirFiles(dir) {
1077+
ext := filepath.Ext(name)
1078+
for _, want := range exts {
1079+
if ext == want {
1080+
return true
1081+
}
1082+
}
1083+
}
1084+
return false
1085+
}
1086+
10021087
// detectResources checks for project resource files defined in the knowledge base.
10031088
func (e *Engine) detectResources() *brief.ResourceInfo {
10041089
if len(e.KB.Resources) == 0 {

detect/detect_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,131 @@ func TestPythonProject(t *testing.T) {
374374
}
375375
}
376376

377+
func writeProjectFile(t *testing.T, dir, path, content string) {
378+
t.Helper()
379+
full := filepath.Join(dir, path)
380+
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
381+
t.Fatal(err)
382+
}
383+
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
384+
t.Fatal(err)
385+
}
386+
}
387+
388+
func runOn(t *testing.T, dir string) *brief.Report {
389+
t.Helper()
390+
r, err := New(loadKB(t), dir).Run()
391+
if err != nil {
392+
t.Fatalf("Run: %v", err)
393+
}
394+
return r
395+
}
396+
397+
func packageManagerNames(r *brief.Report) []string {
398+
names := make([]string, 0, len(r.PackageManagers))
399+
for _, pm := range r.PackageManagers {
400+
names = append(names, pm.Name)
401+
}
402+
return names
403+
}
404+
405+
func TestPythonPackageManagerManifests(t *testing.T) {
406+
cases := []struct {
407+
name string
408+
file string
409+
content string
410+
want string
411+
}{
412+
{"setup.py", "setup.py", "from setuptools import setup\nsetup()\n", "setuptools"},
413+
{"setup.cfg", "setup.cfg", "[metadata]\nname = x\n", "setuptools"},
414+
{"pyproject setuptools", "pyproject.toml", "[build-system]\nrequires = [\"setuptools\"]\n", "setuptools"},
415+
{"pyproject hatchling", "pyproject.toml", "[build-system]\nbuild-backend = \"hatchling.build\"\n", "Hatch"},
416+
{"pyproject flit", "pyproject.toml", "[build-system]\nrequires = [\"flit_core\"]\n", "Flit"},
417+
{"Pipfile", "Pipfile", "[packages]\n", "Pipenv"},
418+
{"requirements.txt", "requirements.txt", "requests\n", "pip"},
419+
}
420+
for _, tc := range cases {
421+
t.Run(tc.name, func(t *testing.T) {
422+
dir := t.TempDir()
423+
writeProjectFile(t, dir, "main.py", "")
424+
writeProjectFile(t, dir, tc.file, tc.content)
425+
r := runOn(t, dir)
426+
got := packageManagerNames(r)
427+
found := false
428+
for _, n := range got {
429+
if n == tc.want {
430+
found = true
431+
}
432+
}
433+
if !found {
434+
t.Errorf("manifest %s: want %q in package managers, got %v", tc.file, tc.want, got)
435+
}
436+
})
437+
}
438+
}
439+
440+
func TestPythonFlatLayout(t *testing.T) {
441+
dir := t.TempDir()
442+
writeProjectFile(t, dir, "setup.py", "")
443+
writeProjectFile(t, dir, "mypkg/__init__.py", "")
444+
writeProjectFile(t, dir, "mypkg/core.py", "")
445+
writeProjectFile(t, dir, "tests/test_core.py", "")
446+
writeProjectFile(t, dir, "docs/index.md", "")
447+
writeProjectFile(t, dir, "scripts/release.py", "")
448+
449+
r := runOn(t, dir)
450+
if r.Layout == nil {
451+
t.Fatal("expected layout info")
452+
}
453+
if got := r.Layout.SourceDirs; len(got) != 1 || got[0] != "mypkg" {
454+
t.Errorf("source_dirs = %v, want [mypkg]", got)
455+
}
456+
for _, d := range r.Layout.SourceDirs {
457+
if d == "docs" || d == "scripts" || d == "tests" {
458+
t.Errorf("source_dirs should not include %q", d)
459+
}
460+
}
461+
foundTests := false
462+
for _, d := range r.Layout.TestDirs {
463+
if d == "tests" {
464+
foundTests = true
465+
}
466+
}
467+
if !foundTests {
468+
t.Error("expected tests/ in test_dirs")
469+
}
470+
}
471+
472+
func TestFlatLayoutSkippedWhenSrcExists(t *testing.T) {
473+
dir := t.TempDir()
474+
writeProjectFile(t, dir, "src/mypkg/__init__.py", "")
475+
writeProjectFile(t, dir, "helper/tool.py", "")
476+
477+
r := runOn(t, dir)
478+
if r.Layout == nil {
479+
t.Fatal("expected layout info")
480+
}
481+
if got := r.Layout.SourceDirs; len(got) != 1 || got[0] != "src" {
482+
t.Errorf("source_dirs = %v, want [src]", got)
483+
}
484+
}
485+
486+
func TestFlatLayoutNonPython(t *testing.T) {
487+
dir := t.TempDir()
488+
writeProjectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\n")
489+
writeProjectFile(t, dir, "mygem/version.rb", "VERSION = '1.0'\n")
490+
writeProjectFile(t, dir, "examples/demo.rb", "")
491+
writeProjectFile(t, dir, "spec/mygem_spec.rb", "")
492+
493+
r := runOn(t, dir)
494+
if r.Layout == nil {
495+
t.Fatal("expected layout info")
496+
}
497+
if got := r.Layout.SourceDirs; len(got) != 1 || got[0] != "mygem" {
498+
t.Errorf("source_dirs = %v, want [mygem]", got)
499+
}
500+
}
501+
377502
func TestEmptyProject(t *testing.T) {
378503
engine := New(loadKB(t), "../testdata/empty-project")
379504
r, err := engine.Run()

kb/kb.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,9 @@ type LayoutDef struct {
161161

162162
// LayoutRules holds the layout detection rules.
163163
type LayoutRules struct {
164-
SourceDirs []string `toml:"source_dirs"` // directories that indicate source
165-
TestDirs []string `toml:"test_dirs"` // directories that indicate tests
164+
SourceDirs []string `toml:"source_dirs"` // directories that indicate source
165+
TestDirs []string `toml:"test_dirs"` // directories that indicate tests
166+
ExcludeDirs []string `toml:"exclude_dirs"` // directories to skip during flat-layout inference
166167
}
167168

168169
// StyleConfigDef defines style configuration files to check.

knowledge/_shared/_layout.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
11
[layout]
22
source_dirs = ["src", "lib", "app", "pkg", "internal", "cmd"]
33
test_dirs = ["test", "tests", "spec", "__tests__", "t"]
4+
exclude_dirs = [
5+
"docs", "doc", "documentation",
6+
"examples", "example", "samples", "sample", "demo", "demos",
7+
"scripts", "script", "bin", "tools", "tool", "utils", "util",
8+
"dev", "ci", "etc", "config", "configs", "conf",
9+
"assets", "static", "public", "resources", "res",
10+
"fixtures", "stubs", "mocks", "benchmark", "benchmarks", "bench",
11+
"man", "share", "data", "migrations", "db",
12+
"requires", "requirements", "deps",
13+
]

knowledge/python/flit.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[tool]
2+
name = "Flit"
3+
category = "package_manager"
4+
homepage = "https://flit.pypa.io"
5+
docs = "https://flit.pypa.io/en/stable/"
6+
repo = "https://github.com/pypa/flit"
7+
description = "Simple Python build backend for pure-Python packages"
8+
9+
[detect]
10+
[detect.file_contains]
11+
"pyproject.toml" = ["[tool.flit", "flit_core"]
12+
13+
ecosystems = ["python"]
14+
15+
[commands]
16+
run = "flit install"
17+
alternatives = ["pip install -e ."]
18+
19+
[config]
20+
files = ["pyproject.toml"]
21+
22+
[taxonomy]
23+
role = ["package-manager"]

knowledge/python/hatch.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[tool]
2+
name = "Hatch"
3+
category = "package_manager"
4+
homepage = "https://hatch.pypa.io"
5+
docs = "https://hatch.pypa.io/latest/"
6+
repo = "https://github.com/pypa/hatch"
7+
description = "Modern Python project manager and build backend"
8+
9+
[detect]
10+
files = ["hatch.toml"]
11+
[detect.file_contains]
12+
"pyproject.toml" = ["[tool.hatch", "hatchling"]
13+
14+
ecosystems = ["python"]
15+
16+
[commands]
17+
run = "hatch env create"
18+
alternatives = ["pip install -e ."]
19+
20+
[config]
21+
files = ["pyproject.toml", "hatch.toml"]
22+
23+
[taxonomy]
24+
role = ["package-manager"]

knowledge/python/setuptools.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[tool]
2+
name = "setuptools"
3+
category = "package_manager"
4+
homepage = "https://setuptools.pypa.io"
5+
docs = "https://setuptools.pypa.io/en/latest/"
6+
repo = "https://github.com/pypa/setuptools"
7+
description = "Python build backend for setup.py/setup.cfg and PEP 517 projects"
8+
9+
[detect]
10+
files = ["setup.py", "setup.cfg"]
11+
[detect.file_contains]
12+
"pyproject.toml" = ["setuptools"]
13+
14+
ecosystems = ["python"]
15+
16+
[commands]
17+
run = "pip install -e ."
18+
alternatives = ["pip install ."]
19+
20+
[config]
21+
files = ["setup.py", "setup.cfg", "pyproject.toml"]
22+
23+
[taxonomy]
24+
role = ["package-manager"]

0 commit comments

Comments
 (0)