Skip to content

Commit 2b1c8bd

Browse files
joke1196sonartech
authored andcommitted
SONARPY-3802 Corrected package root computation (#860)
GitOrigin-RevId: 5363c8fbdb6e649aecbc658e43c8a5ce23263b90
1 parent c80a9f5 commit 2b1c8bd

File tree

13 files changed

+619
-62
lines changed

13 files changed

+619
-62
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.plugins.python.indexer;
18+
19+
import java.io.File;
20+
import java.util.List;
21+
22+
/**
23+
* Represents source roots extracted from a build configuration file (pyproject.toml or setup.py).
24+
*
25+
* <p>This record associates relative source root paths with the configuration file they were
26+
* extracted from, allowing proper resolution of absolute paths relative to the config file's
27+
* location rather than the project base directory.
28+
*
29+
* @param configFile the configuration file from which the source roots were extracted
30+
* @param relativeRoots the relative source root paths defined in the config file
31+
*/
32+
public record ConfigSourceRoots(File configFile, List<String> relativeRoots) {
33+
34+
/**
35+
* Converts the relative source roots to absolute paths.
36+
*
37+
* <p>Each relative path is resolved relative to the parent directory of the configuration file.
38+
* For example, if the config file is at {@code /project/app/pyproject.toml} and a relative root
39+
* is {@code "src"}, the absolute path will be {@code /project/app/src}.
40+
*
41+
* @return list of absolute paths for the source roots
42+
*/
43+
public List<String> toAbsolutePaths() {
44+
File parentDir = configFile.getParentFile();
45+
if (parentDir == null) {
46+
return List.of();
47+
}
48+
return relativeRoots.stream()
49+
.map(root -> new File(parentDir, root).getAbsolutePath())
50+
.toList();
51+
}
52+
53+
/**
54+
* Creates a ConfigSourceRoots with an empty list of relative roots.
55+
*
56+
* @param configFile the configuration file
57+
* @return a ConfigSourceRoots with no relative roots
58+
*/
59+
public static ConfigSourceRoots empty(File configFile) {
60+
return new ConfigSourceRoots(configFile, List.of());
61+
}
62+
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,19 @@ private PackageRootResolver() {
3838
/**
3939
* Resolves package root directories.
4040
*
41-
* <p>If extracted roots from build system configuration are provided, resolves them to absolute paths.
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.
4243
* Otherwise, applies a fallback chain to determine appropriate roots.
4344
*
44-
* @param extractedRoots roots extracted from build system config (e.g., from BuildSystemSourceRoots.extract())
45-
* @param config the Sonar configuration to read sonar.sources property
46-
* @param baseDir the project base directory
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)
4748
* @return list of resolved package root absolute paths
4849
*/
4950
public static List<String> resolve(List<String> extractedRoots, Configuration config, File baseDir) {
5051
if (!extractedRoots.isEmpty()) {
51-
return toAbsolutePaths(extractedRoots, baseDir);
52+
// Extracted roots are already absolute paths (resolved relative to config file location)
53+
return extractedRoots;
5254
}
5355
return resolveFallback(config, baseDir);
5456
}

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.fasterxml.jackson.annotation.Nulls;
2222
import com.fasterxml.jackson.databind.DeserializationFeature;
2323
import com.fasterxml.jackson.dataformat.toml.TomlMapper;
24+
import com.google.common.annotations.VisibleForTesting;
2425
import java.io.File;
2526
import java.io.IOException;
2627
import java.nio.file.Files;
@@ -62,7 +63,8 @@ private PyProjectTomlSourceRoots() {
6263
* @param tomlContent the content of a pyproject.toml file
6364
* @return list of source root paths (relative), empty if none found or on parse error
6465
*/
65-
public static List<String> extract(String tomlContent) {
66+
@VisibleForTesting
67+
static List<String> extract(String tomlContent) {
6668
try {
6769
PyProjectConfig config = TOML_MAPPER.readValue(tomlContent, PyProjectConfig.class);
6870
return extractFromConfig(config);
@@ -77,14 +79,29 @@ public static List<String> extract(String tomlContent) {
7779
* @param file the pyproject.toml file
7880
* @return list of source root paths (relative), empty if none found or on parse error
7981
*/
80-
public static List<String> extract(File file) {
82+
private static List<String> extract(File file) {
8183
try {
8284
return extract(Files.readString(file.toPath()));
8385
} catch (IOException e) {
8486
return List.of();
8587
}
8688
}
8789

90+
/**
91+
* Extracts source root directories from a pyproject.toml File, preserving the config file location.
92+
*
93+
* <p>This method returns a {@link ConfigSourceRoots} that associates the extracted relative paths
94+
* with the config file, allowing callers to resolve absolute paths relative to the config file's
95+
* directory rather than the project base directory.
96+
*
97+
* @param file the pyproject.toml file
98+
* @return ConfigSourceRoots containing the config file and its relative source roots
99+
*/
100+
public static ConfigSourceRoots extractWithLocation(File file) {
101+
List<String> roots = extract(file);
102+
return new ConfigSourceRoots(file, roots);
103+
}
104+
88105
private static List<String> extractFromConfig(PyProjectConfig config) {
89106
Set<String> sourceRoots = new LinkedHashSet<>();
90107

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,19 @@ protected List<String> resolvePackageRoots(SensorContext context) {
134134
/**
135135
* Extracts source root directories from build configuration files (pyproject.toml and setup.py).
136136
*
137+
* <p>Source roots are resolved to absolute paths relative to the configuration file's directory,
138+
* not the project base directory. This correctly handles cases where config files are in
139+
* subdirectories (e.g., monorepos with multiple Python projects).
140+
*
137141
* @param fileSystem the file system to search for configuration files
138-
* @return list of extracted source root paths (relative), or empty list if none found
142+
* @return list of extracted source root absolute paths, or empty list if none found
139143
*/
140144
private static List<String> extractSourceRoots(FileSystem fileSystem) {
141-
List<String> pyprojectRoots = extractSourceRootsFromPyProjectToml(fileSystem);
142-
List<String> setupPyRoots = extractSourceRootsFromSetupPy(fileSystem);
145+
List<ConfigSourceRoots> pyprojectRoots = extractSourceRootsFromPyProjectToml(fileSystem);
146+
List<ConfigSourceRoots> setupPyRoots = extractSourceRootsFromSetupPy(fileSystem);
143147

144148
return Stream.concat(pyprojectRoots.stream(), setupPyRoots.stream())
149+
.flatMap(csr -> csr.toAbsolutePaths().stream())
145150
.distinct()
146151
.toList();
147152
}
@@ -195,25 +200,25 @@ private static Stream<File> findFilesRecursively(FileSystem fileSystem, String f
195200
* Extracts source root directories from pyproject.toml files in the project.
196201
*
197202
* @param fileSystem the file system to search for pyproject.toml
198-
* @return list of extracted source root paths (relative), or empty list if none found
203+
* @return list of ConfigSourceRoots containing config file locations and their relative roots
199204
*/
200-
private static List<String> extractSourceRootsFromPyProjectToml(FileSystem fileSystem) {
205+
private static List<ConfigSourceRoots> extractSourceRootsFromPyProjectToml(FileSystem fileSystem) {
201206
return findFilesRecursively(fileSystem, "pyproject.toml")
202-
.flatMap(file -> PyProjectTomlSourceRoots.extract(file).stream())
203-
.distinct()
207+
.map(PyProjectTomlSourceRoots::extractWithLocation)
208+
.filter(csr -> !csr.relativeRoots().isEmpty())
204209
.toList();
205210
}
206211

207212
/**
208213
* Extracts source root directories from setup.py files in the project.
209214
*
210215
* @param fileSystem the file system to search for setup.py
211-
* @return list of extracted source root paths (relative), or empty list if none found
216+
* @return list of ConfigSourceRoots containing config file locations and their relative roots
212217
*/
213-
private static List<String> extractSourceRootsFromSetupPy(FileSystem fileSystem) {
218+
private static List<ConfigSourceRoots> extractSourceRootsFromSetupPy(FileSystem fileSystem) {
214219
return findFilesRecursively(fileSystem, "setup.py")
215-
.flatMap(file -> SetupPySourceRoots.extract(file).stream())
216-
.distinct()
220+
.map(SetupPySourceRoots::extractWithLocation)
221+
.filter(csr -> !csr.relativeRoots().isEmpty())
217222
.toList();
218223
}
219224

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
package org.sonar.plugins.python.indexer;
1818

19+
import com.google.common.annotations.VisibleForTesting;
1920
import com.sonar.sslr.api.AstNode;
2021
import java.io.File;
2122
import java.io.IOException;
@@ -58,7 +59,8 @@ private SetupPySourceRoots() {
5859
* @param setupPyContent the content of a setup.py file
5960
* @return list of source root paths (relative), empty if none found or on parse error
6061
*/
61-
public static List<String> extract(String setupPyContent) {
62+
@VisibleForTesting
63+
static List<String> extract(String setupPyContent) {
6264
try {
6365
PythonParser parser = PythonParser.create();
6466
AstNode astNode = parser.parse(setupPyContent);
@@ -88,6 +90,21 @@ public static List<String> extract(File file) {
8890
}
8991
}
9092

93+
/**
94+
* Extracts source root directories from a setup.py File, preserving the config file location.
95+
*
96+
* <p>This method returns a {@link ConfigSourceRoots} that associates the extracted relative paths
97+
* with the config file, allowing callers to resolve absolute paths relative to the config file's
98+
* directory rather than the project base directory.
99+
*
100+
* @param file the setup.py file
101+
* @return ConfigSourceRoots containing the config file and its relative source roots
102+
*/
103+
public static ConfigSourceRoots extractWithLocation(File file) {
104+
List<String> roots = extract(file);
105+
return new ConfigSourceRoots(file, roots);
106+
}
107+
91108
private static class SetupCallVisitor extends BaseTreeVisitor {
92109
private final Set<String> sourceRoots = new LinkedHashSet<>();
93110
private final Map<String, Expression> variables = new HashMap<>();
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.plugins.python.indexer;
18+
19+
import java.io.File;
20+
import java.io.IOException;
21+
import java.nio.file.Files;
22+
import java.nio.file.Path;
23+
import java.util.List;
24+
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.io.TempDir;
26+
27+
import static org.assertj.core.api.Assertions.assertThat;
28+
29+
class ConfigSourceRootsTest {
30+
31+
@TempDir
32+
Path tempDir;
33+
34+
@Test
35+
void toAbsolutePaths_resolvesRelativeToConfigFileParent() throws IOException {
36+
Path subDir = tempDir.resolve("subproject");
37+
Files.createDirectories(subDir);
38+
File configFile = subDir.resolve("pyproject.toml").toFile();
39+
Files.writeString(configFile.toPath(), "");
40+
41+
ConfigSourceRoots csr = new ConfigSourceRoots(configFile, List.of("src", "lib"));
42+
43+
assertThat(csr.toAbsolutePaths()).containsExactly(
44+
subDir.resolve("src").toFile().getAbsolutePath(),
45+
subDir.resolve("lib").toFile().getAbsolutePath()
46+
);
47+
}
48+
49+
@Test
50+
void toAbsolutePaths_emptyListForEmptyRelativeRoots() throws IOException {
51+
File configFile = tempDir.resolve("pyproject.toml").toFile();
52+
Files.writeString(configFile.toPath(), "");
53+
54+
ConfigSourceRoots csr = new ConfigSourceRoots(configFile, List.of());
55+
56+
assertThat(csr.toAbsolutePaths()).isEmpty();
57+
}
58+
59+
@Test
60+
void toAbsolutePaths_handlesNestedDirectories() throws IOException {
61+
Path deepDir = tempDir.resolve("a/b/c");
62+
Files.createDirectories(deepDir);
63+
File configFile = deepDir.resolve("pyproject.toml").toFile();
64+
Files.writeString(configFile.toPath(), "");
65+
66+
ConfigSourceRoots csr = new ConfigSourceRoots(configFile, List.of("src"));
67+
68+
assertThat(csr.toAbsolutePaths()).containsExactly(
69+
deepDir.resolve("src").toFile().getAbsolutePath()
70+
);
71+
}
72+
73+
@Test
74+
void empty_createsConfigSourceRootsWithEmptyRelativeRoots() throws IOException {
75+
File configFile = tempDir.resolve("pyproject.toml").toFile();
76+
Files.writeString(configFile.toPath(), "");
77+
78+
ConfigSourceRoots csr = ConfigSourceRoots.empty(configFile);
79+
80+
assertThat(csr.configFile()).isEqualTo(configFile);
81+
assertThat(csr.relativeRoots()).isEmpty();
82+
assertThat(csr.toAbsolutePaths()).isEmpty();
83+
}
84+
85+
@Test
86+
void configFile_returnsOriginalFile() throws IOException {
87+
File configFile = tempDir.resolve("setup.py").toFile();
88+
Files.writeString(configFile.toPath(), "");
89+
90+
ConfigSourceRoots csr = new ConfigSourceRoots(configFile, List.of("src"));
91+
92+
assertThat(csr.configFile()).isSameAs(configFile);
93+
}
94+
95+
@Test
96+
void relativeRoots_returnsOriginalList() throws IOException {
97+
File configFile = tempDir.resolve("pyproject.toml").toFile();
98+
Files.writeString(configFile.toPath(), "");
99+
List<String> roots = List.of("src", "lib");
100+
101+
ConfigSourceRoots csr = new ConfigSourceRoots(configFile, roots);
102+
103+
assertThat(csr.relativeRoots()).isEqualTo(roots);
104+
}
105+
106+
@Test
107+
void toAbsolutePaths_returnsEmptyListWhenParentDirIsNull() {
108+
// A File with no parent (relative path with no directory component)
109+
File configFile = new File("pyproject.toml");
110+
111+
ConfigSourceRoots csr = new ConfigSourceRoots(configFile, List.of("src", "lib"));
112+
113+
assertThat(csr.toAbsolutePaths()).isEmpty();
114+
}
115+
}

python-commons/src/test/java/org/sonar/plugins/python/indexer/PackageRootResolverTest.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,23 @@ class PackageRootResolverTest {
3535
Path tempDir;
3636

3737
@Test
38-
void resolve_withExtractedRoots_returnsAbsolutePaths() {
38+
void resolve_withExtractedRoots_returnsThemDirectly() {
39+
// Extracted roots are now expected to be already absolute paths
40+
// (resolved relative to the config file location by PythonIndexer)
3941
Configuration config = mock(Configuration.class);
4042
when(config.getStringArray("sonar.sources")).thenReturn(new String[0]);
4143

4244
File baseDir = tempDir.toFile();
43-
List<String> extractedRoots = List.of("src", "lib");
45+
List<String> extractedRoots = List.of(
46+
new File(baseDir, "subproject/src").getAbsolutePath(),
47+
new File(baseDir, "subproject/lib").getAbsolutePath()
48+
);
4449
List<String> result = PackageRootResolver.resolve(extractedRoots, config, baseDir);
4550

51+
// Should return the paths as-is since they're already absolute
4652
assertThat(result).containsExactly(
47-
new File(baseDir, "src").getAbsolutePath(),
48-
new File(baseDir, "lib").getAbsolutePath());
53+
new File(baseDir, "subproject/src").getAbsolutePath(),
54+
new File(baseDir, "subproject/lib").getAbsolutePath());
4955
}
5056

5157
@Test
@@ -54,10 +60,12 @@ void resolve_withExtractedRoots_ignoresSonarSources() {
5460
when(config.getStringArray("sonar.sources")).thenReturn(new String[]{"other"});
5561

5662
File baseDir = tempDir.toFile();
57-
List<String> extractedRoots = List.of("src");
63+
// Extracted roots are already absolute paths
64+
List<String> extractedRoots = List.of(new File(baseDir, "app/src").getAbsolutePath());
5865
List<String> result = PackageRootResolver.resolve(extractedRoots, config, baseDir);
5966

60-
assertThat(result).containsExactly(new File(baseDir, "src").getAbsolutePath());
67+
// Should return the extracted root as-is, ignoring sonar.sources
68+
assertThat(result).containsExactly(new File(baseDir, "app/src").getAbsolutePath());
6169
}
6270

6371
@Test

0 commit comments

Comments
 (0)