Skip to content

Commit aad7033

Browse files
joke1196sonartech
authored andcommitted
SONARPY-4082 Refactoring PyProject and Setup.py parsing to PackageRootResolver. (#1067)
GitOrigin-RevId: ca3a6b024c0484fcde49cf2cd3611a9c873057b3
1 parent 649526a commit aad7033

5 files changed

Lines changed: 411 additions & 442 deletions

File tree

python-commons/src/main/java/org/sonar/plugins/python/indexer/PackageRootResolver.java

Lines changed: 180 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,76 +16,157 @@
1616
*/
1717
package org.sonar.plugins.python.indexer;
1818

19+
import com.google.common.annotations.VisibleForTesting;
1920
import java.io.File;
21+
import java.io.IOException;
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
2024
import java.util.ArrayList;
2125
import java.util.Arrays;
2226
import java.util.List;
27+
import java.util.Optional;
28+
import java.util.function.BiFunction;
29+
import java.util.stream.Stream;
30+
import org.slf4j.Logger;
31+
import org.slf4j.LoggerFactory;
32+
import org.sonar.api.batch.fs.FileSystem;
2333
import org.sonar.api.config.Configuration;
2434

2535
/**
2636
* Resolves package root directories for Python projects.
2737
*
28-
* <p>This class validates and resolves package roots extracted from build system configurations
29-
* (e.g., pyproject.toml) and provides fallback resolution when no roots are configured.
38+
* <p>This class is the single source of truth for package root resolution. It handles:
39+
* <ul>
40+
* <li>Extraction from pyproject.toml build system configurations</li>
41+
* <li>Extraction from setup.py configurations</li>
42+
* <li>Fallback when build files exist but provide no roots: conventional folders (src/, lib/),
43+
* then sonar.sources, then base directory</li>
44+
* <li>Fallback when no build files exist: sonar.sources, then conventional folders (src/, lib/),
45+
* then base directory</li>
46+
* </ul>
3047
*/
3148
public class PackageRootResolver {
49+
50+
private static final Logger LOG = LoggerFactory.getLogger(PackageRootResolver.class);
51+
3252
static final String SONAR_SOURCES_KEY = "sonar.sources";
3353
static final List<String> CONVENTIONAL_FOLDERS = List.of("src", "lib");
3454

3555
private PackageRootResolver() {
3656
}
3757

3858
/**
39-
* Resolves package root directories.
59+
* Resolves package root directories for the project.
4060
*
41-
* <p>If extracted roots from build system configuration are provided (already as absolute paths
42-
* resolved relative to their config file locations), returns them directly.
43-
* Otherwise, applies a fallback chain to determine appropriate roots.
61+
* <p>Attempts to extract source roots from pyproject.toml and setup.py build system configurations.
62+
* Falls back to different priority orders depending on whether build files exist: when build files
63+
* are present but provide no source roots, conventional folders take priority over sonar.sources;
64+
* when no build files exist at all, sonar.sources takes priority over conventional folders.
4465
*
45-
* @param extractedRoots roots extracted from build system config, already as absolute paths
46-
* @param config the Sonar configuration to read sonar.sources property
47-
* @param baseDir the project base directory (used only for fallback resolution)
48-
* @return list of resolved package root absolute paths
66+
* @param fileSystem the Sonar file system providing the base directory
67+
* @param config the Sonar configuration
68+
* @return resolution result including resolved root absolute paths and method information
4969
*/
50-
public static List<String> resolve(List<String> extractedRoots, Configuration config, File baseDir) {
51-
if (!extractedRoots.isEmpty()) {
52-
// Extracted roots are already absolute paths (resolved relative to config file location)
53-
return extractedRoots;
70+
public static PackageResolutionResult resolve(FileSystem fileSystem, Configuration config) {
71+
File baseDir = fileSystem.baseDir();
72+
73+
// Discover build config files
74+
List<File> pyprojectFiles = findFilesRecursively(fileSystem, "pyproject.toml");
75+
List<File> setupPyFiles = findFilesRecursively(fileSystem, "setup.py");
76+
boolean hasBuildConfigFiles = !pyprojectFiles.isEmpty() || !setupPyFiles.isEmpty();
77+
78+
// Extract source roots from discovered files
79+
List<PyProjectExtractionResult> pyprojectResults = pyprojectFiles.stream()
80+
.map(PyProjectTomlSourceRoots::extractWithBuildSystem)
81+
.filter(PyProjectExtractionResult::hasRoots)
82+
.toList();
83+
List<ConfigSourceRoots> setupPyRoots = setupPyFiles.stream()
84+
.map(SetupPySourceRoots::extractWithLocation)
85+
.filter(csr -> !csr.relativeRoots().isEmpty())
86+
.toList();
87+
88+
boolean hasPyproject = pyprojectResults.stream().anyMatch(PyProjectExtractionResult::hasRoots);
89+
boolean hasSetupPy = !setupPyRoots.isEmpty();
90+
91+
List<String> combinedRoots = Stream.concat(
92+
pyprojectResults.stream().map(PyProjectExtractionResult::configRoots).flatMap(crs -> crs.toAbsolutePaths().stream()),
93+
setupPyRoots.stream().flatMap(csr -> csr.toAbsolutePaths().stream()))
94+
.distinct()
95+
.toList();
96+
97+
List<String> adjustedRoots = adjustRoots(combinedRoots, baseDir);
98+
LOG.debug("Resolved package roots from build configuration: {}", adjustedRoots);
99+
100+
if (hasPyproject && hasSetupPy) {
101+
return PackageResolutionResult.fromBothPyProjectAndSetupPy(adjustedRoots, getCombinedBuildSystem(pyprojectResults));
102+
}
103+
104+
if (hasPyproject) {
105+
return PackageResolutionResult.fromPyProjectToml(adjustedRoots, getCombinedBuildSystem(pyprojectResults));
54106
}
55-
return resolveFallback(config, baseDir);
107+
108+
if (hasSetupPy) {
109+
return PackageResolutionResult.fromSetupPy(adjustedRoots);
110+
}
111+
112+
return resolveFallback(config, baseDir, hasBuildConfigFiles);
56113
}
57114

58115
/**
59-
* Resolves fallback package roots when no build system configuration is available.
116+
* Resolves fallback package roots when no build system configuration provides source roots.
60117
*
61-
* <p>Fallback priority:
62-
* <ol>
63-
* <li>sonar.sources property if set</li>
64-
* <li>"src" and/or "lib" folders if they exist</li>
65-
* <li>Project base directory absolute path as last resort</li>
66-
* </ol>
118+
* <p>When build config files (pyproject.toml / setup.py) exist but provide no source roots,
119+
* the priority order is: conventional folders (src/, lib/), then sonar.sources, then base directory.
67120
*
68-
* @param config the Sonar configuration
69-
* @param baseDir the project base directory
70-
* @return list of fallback package root absolute paths
121+
* <p>When NO build config files exist at all, the priority order is: sonar.sources, then
122+
* conventional folders (src/, lib/), then base directory.
71123
*/
72-
static List<String> resolveFallback(Configuration config, File baseDir) {
124+
private static PackageResolutionResult resolveFallback(Configuration config, File baseDir, boolean hasBuildConfigFiles) {
125+
List<BiFunction<Configuration, File, Optional<PackageResolutionResult>>> candidates = hasBuildConfigFiles
126+
? List.of(PackageRootResolver::tryConventionalFolders, PackageRootResolver::trySonarSources)
127+
: List.of(PackageRootResolver::trySonarSources, PackageRootResolver::tryConventionalFolders);
128+
129+
for (BiFunction<Configuration, File, Optional<PackageResolutionResult>> candidate : candidates) {
130+
Optional<PackageResolutionResult> result = candidate.apply(config, baseDir);
131+
if (result.isPresent()) {
132+
return result.get();
133+
}
134+
}
135+
136+
LOG.debug("Using project base directory as package root (fallback)");
137+
return PackageResolutionResult.fromBaseDir(List.of(baseDir.getAbsolutePath()));
138+
}
139+
140+
private static Optional<PackageResolutionResult> tryConventionalFolders(Configuration config, File baseDir) {
73141
List<String> conventionalFolders = findConventionalFolders(baseDir);
74-
if (!conventionalFolders.isEmpty()) {
75-
return toAbsolutePaths(conventionalFolders, baseDir);
142+
if (conventionalFolders.isEmpty()) {
143+
return Optional.empty();
76144
}
145+
List<String> adjustedRoots = adjustRoots(toAbsolutePaths(conventionalFolders, baseDir), baseDir);
146+
LOG.debug("Resolved package roots from fallback (conventional folders): {}", adjustedRoots);
147+
return Optional.of(PackageResolutionResult.fromConventionalFolders(adjustedRoots));
148+
}
77149

150+
private static Optional<PackageResolutionResult> trySonarSources(Configuration config, File baseDir) {
78151
String[] sonarSources = config.getStringArray(SONAR_SOURCES_KEY);
79-
if (sonarSources.length > 0) {
80-
return toAbsolutePaths(Arrays.asList(sonarSources), baseDir);
152+
if (sonarSources.length == 0) {
153+
return Optional.empty();
81154
}
82-
83-
return List.of(baseDir.getAbsolutePath());
155+
List<String> adjustedRoots = adjustRoots(toAbsolutePaths(Arrays.asList(sonarSources), baseDir), baseDir);
156+
LOG.debug("Resolved package roots from fallback (sonar.sources): {}", adjustedRoots);
157+
return Optional.of(PackageResolutionResult.fromSonarSources(adjustedRoots));
84158
}
85159

86-
private static List<String> toAbsolutePaths(List<String> paths, File baseDir) {
160+
/**
161+
* Converts relative path strings to normalized absolute paths under the given base directory.
162+
*
163+
* <p>Uses {@link Path#normalize()} to resolve {@code .} and {@code ..} components without
164+
* performing any I/O, so that {@code sonar.sources=.} correctly resolves to the base directory
165+
* rather than producing an un-normalized path like {@code /project/.}.
166+
*/
167+
static List<String> toAbsolutePaths(List<String> paths, File baseDir) {
87168
return paths.stream()
88-
.map(path -> new File(baseDir, path).getAbsolutePath())
169+
.map(path -> new File(baseDir, path).toPath().normalize().toString())
89170
.toList();
90171
}
91172

@@ -99,5 +180,69 @@ private static List<String> findConventionalFolders(File baseDir) {
99180
}
100181
return folders;
101182
}
102-
}
103183

184+
private static List<String> adjustRoots(List<String> roots, File baseDir) {
185+
return roots.stream()
186+
.map(root -> {
187+
File rootFile = new File(root).isAbsolute() ? new File(root) : new File(baseDir, root);
188+
return adjustPackageRoot(rootFile, baseDir);
189+
})
190+
.distinct()
191+
.toList();
192+
}
193+
194+
/**
195+
* Adjusts a package root by walking up the directory tree if it contains __init__.py.
196+
*
197+
* <p>If the root directory contains __init__.py, it's part of a package, not the package root.
198+
* We walk up to find the first parent directory without __init__.py.
199+
*
200+
* @param root the potential package root directory
201+
* @param baseDir the project base directory (we don't walk above this)
202+
* @return the adjusted package root absolute path
203+
*/
204+
@VisibleForTesting
205+
static String adjustPackageRoot(File root, File baseDir) {
206+
File current = root;
207+
String baseDirPath = baseDir.getAbsolutePath();
208+
while (current != null && !current.getAbsolutePath().equals(baseDirPath)) {
209+
File initFile = new File(current, "__init__.py");
210+
if (!initFile.exists()) {
211+
break;
212+
}
213+
current = current.getParentFile();
214+
}
215+
if (current == null) {
216+
return baseDirPath;
217+
}
218+
return current.getAbsolutePath();
219+
}
220+
221+
/**
222+
* Recursively finds files with the given filename under the project base directory.
223+
*/
224+
private static List<File> findFilesRecursively(FileSystem fileSystem, String filename) {
225+
try (Stream<Path> stream = Files.walk(fileSystem.baseDir().toPath())) {
226+
return stream
227+
.filter(Files::isRegularFile)
228+
.filter(path -> filename.equals(path.getFileName().toString()))
229+
.map(Path::toFile)
230+
.toList();
231+
} catch (IOException e) {
232+
return List.of();
233+
}
234+
}
235+
236+
/**
237+
* Determines the combined build system across multiple pyproject.toml results.
238+
* If multiple files report different build systems, returns MULTIPLE.
239+
*/
240+
private static PackageResolutionResult.BuildSystem getCombinedBuildSystem(List<PyProjectExtractionResult> pyprojectResults) {
241+
return pyprojectResults.stream()
242+
.map(PyProjectExtractionResult::buildSystem)
243+
.filter(bs -> bs != PackageResolutionResult.BuildSystem.NONE)
244+
.distinct()
245+
.reduce((a, b) -> PackageResolutionResult.BuildSystem.MULTIPLE)
246+
.orElse(PackageResolutionResult.BuildSystem.NONE);
247+
}
248+
}

0 commit comments

Comments
 (0)