Skip to content

Commit c2f5c29

Browse files
joke1196sonartech
authored andcommitted
SONARPY-3758: Detect package root for main build-systems (#839)
GitOrigin-RevId: 65258b5eea4fe2c0cc94f3e4512ee981dbd4cc4f
1 parent 474700c commit c2f5c29

2 files changed

Lines changed: 740 additions & 0 deletions

File tree

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
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 com.fasterxml.jackson.annotation.JsonProperty;
20+
import com.fasterxml.jackson.annotation.JsonSetter;
21+
import com.fasterxml.jackson.annotation.Nulls;
22+
import com.fasterxml.jackson.databind.DeserializationFeature;
23+
import com.fasterxml.jackson.dataformat.toml.TomlMapper;
24+
import java.io.IOException;
25+
import java.util.ArrayList;
26+
import java.util.LinkedHashSet;
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.Set;
30+
import javax.annotation.Nonnull;
31+
import javax.annotation.Nullable;
32+
import org.sonar.api.batch.fs.InputFile;
33+
34+
/**
35+
* Extracts source root directories from pyproject.toml build system configurations.
36+
*
37+
* <p>Supports the following build systems:
38+
* <ul>
39+
* <li>setuptools: {@code [tool.setuptools.packages.find] where = ["src"]}</li>
40+
* <li>Poetry: {@code [tool.poetry] packages = [{from = "src", include = "pkg"}]}</li>
41+
* <li>Hatchling: {@code [tool.hatch.build.targets.wheel] sources = ["src"]}</li>
42+
* <li>uv_build: {@code [tool.uv.build-backend] module-root = "src"} and by default detect src by convention</li>
43+
* <li>PDM: {@code [tool.pdm] package-dir = "src"}</li>
44+
* <li>Flit: auto-detects src/ layout by convention</li>
45+
* </ul>
46+
*/
47+
public class BuildSystemSourceRoots {
48+
49+
private static final TomlMapper TOML_MAPPER = new TomlMapper();
50+
51+
static {
52+
TOML_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
53+
}
54+
55+
private BuildSystemSourceRoots() {
56+
}
57+
58+
/**
59+
* Extracts source root directories from pyproject.toml content.
60+
*
61+
* @param tomlContent the content of a pyproject.toml file
62+
* @return list of source root paths (relative), empty if none found or on parse error
63+
*/
64+
public static List<String> extract(String tomlContent) {
65+
try {
66+
PyProjectConfig config = TOML_MAPPER.readValue(tomlContent, PyProjectConfig.class);
67+
return extractFromConfig(config);
68+
} catch (IOException e) {
69+
return List.of();
70+
}
71+
}
72+
73+
/**
74+
* Extracts source root directories from a pyproject.toml InputFile.
75+
*
76+
* @param inputFile the pyproject.toml file
77+
* @return list of source root paths (relative), empty if none found or on parse error
78+
*/
79+
public static List<String> extract(InputFile inputFile) {
80+
try {
81+
return extract(inputFile.contents());
82+
} catch (IOException e) {
83+
return List.of();
84+
}
85+
}
86+
87+
private static List<String> extractFromConfig(PyProjectConfig config) {
88+
if (config.tool() == null) {
89+
return List.of();
90+
}
91+
92+
Set<String> sourceRoots = new LinkedHashSet<>();
93+
Tool tool = config.tool();
94+
95+
sourceRoots.addAll(extractFromSetuptools(tool.setuptools()));
96+
sourceRoots.addAll(extractFromPoetry(tool.poetry()));
97+
sourceRoots.addAll(extractFromHatchling(tool.hatch()));
98+
sourceRoots.addAll(extractFromUvBuild(tool.uv()));
99+
sourceRoots.addAll(extractFromPdm(tool.pdm()));
100+
sourceRoots.addAll(extractFromFlit(tool.flit()));
101+
102+
return new ArrayList<>(sourceRoots);
103+
}
104+
105+
// === Setuptools ===
106+
// [tool.setuptools.packages.find]
107+
// where = ["src"]
108+
109+
private static List<String> extractFromSetuptools(@Nullable Setuptools setuptools) {
110+
if (setuptools == null || setuptools.packages() == null || setuptools.packages().find() == null) {
111+
return List.of();
112+
}
113+
return setuptools.packages().find().where();
114+
}
115+
116+
// === Poetry ===
117+
// [tool.poetry]
118+
// packages = [{ include = "mypackage", from = "src" }]
119+
120+
private static List<String> extractFromPoetry(@Nullable Poetry poetry) {
121+
if (poetry == null) {
122+
return List.of();
123+
}
124+
return poetry.packages().stream()
125+
.map(PoetryPackage::from)
126+
.filter(from -> from != null && !from.isEmpty())
127+
.distinct()
128+
.toList();
129+
}
130+
131+
// === Hatchling ===
132+
// [tool.hatch.build.targets.wheel]
133+
// sources = ["src"]
134+
// OR packages = ["src/mypackage"]
135+
136+
private static List<String> extractFromHatchling(@Nullable Hatch hatch) {
137+
if (hatch == null || hatch.build() == null || hatch.build().targets() == null
138+
|| hatch.build().targets().wheel() == null) {
139+
return List.of();
140+
}
141+
142+
HatchWheel wheel = hatch.build().targets().wheel();
143+
144+
// Prefer explicit sources
145+
if (!wheel.sources().isEmpty()) {
146+
return wheel.sources();
147+
}
148+
149+
// Fall back to parsing directory from packages
150+
if (!wheel.packages().isEmpty()) {
151+
return wheel.packages().stream()
152+
.map(BuildSystemSourceRoots::extractDirectoryFromPackagePath)
153+
.filter(dir -> dir != null && !dir.isEmpty())
154+
.distinct()
155+
.toList();
156+
}
157+
158+
return List.of();
159+
}
160+
161+
private static String extractDirectoryFromPackagePath(String packagePath) {
162+
// Normalize Windows paths to Unix-style
163+
String normalizedPath = packagePath.replace('\\', '/');
164+
int slashIndex = normalizedPath.indexOf('/');
165+
if (slashIndex > 0) {
166+
return normalizedPath.substring(0, slashIndex);
167+
}
168+
return null;
169+
}
170+
171+
// === uv_build ===
172+
// [tool.uv.build-backend]
173+
// module-root = "src"
174+
175+
// uv auto-detects src/ layout by convention
176+
// No explicit configuration needed - returns "src" if uv module is configured
177+
178+
private static List<String> extractFromUvBuild(@Nullable Uv uv) {
179+
if (uv == null || uv.buildBackend() == null) {
180+
return List.of();
181+
}
182+
String moduleRoot = uv.buildBackend().moduleRoot();
183+
if (moduleRoot != null && !moduleRoot.isEmpty()) {
184+
return List.of(moduleRoot);
185+
}
186+
return List.of("src");
187+
}
188+
189+
// === PDM ===
190+
// [tool.pdm]
191+
// package-dir = "src"
192+
193+
private static List<String> extractFromPdm(@Nullable Pdm pdm) {
194+
if (pdm == null) {
195+
return List.of();
196+
}
197+
String packageDir = pdm.packageDir();
198+
if (packageDir != null && !packageDir.isEmpty()) {
199+
return List.of(packageDir);
200+
}
201+
return List.of();
202+
}
203+
204+
// === Flit ===
205+
// Flit auto-detects src/ layout by convention
206+
// No explicit configuration needed - returns "src" if flit module is configured
207+
208+
private static List<String> extractFromFlit(@Nullable Flit flit) {
209+
// Flit doesn't have explicit source root config
210+
// It auto-detects src/ layout when the module isn't at project root
211+
// We return "src" as a hint when flit.module is configured
212+
if (flit != null && flit.module() != null && flit.module().name() != null) {
213+
return List.of("src");
214+
}
215+
return List.of();
216+
}
217+
218+
// === TOML Record Definitions ===
219+
220+
private record PyProjectConfig(
221+
@JsonProperty("build-system") @Nullable BuildSystem buildSystem,
222+
@Nullable Tool tool
223+
) {}
224+
225+
private record BuildSystem(
226+
@JsonProperty("build-backend") @Nullable String buildBackend,
227+
@JsonSetter(nulls = Nulls.AS_EMPTY) @Nonnull List<String> requires
228+
) {
229+
BuildSystem {
230+
requires = requires != null ? requires : List.of();
231+
}
232+
}
233+
234+
private record Tool(
235+
@Nullable Setuptools setuptools,
236+
@Nullable Poetry poetry,
237+
@Nullable Hatch hatch,
238+
@Nullable Uv uv,
239+
@Nullable Pdm pdm,
240+
@Nullable Flit flit
241+
) {}
242+
243+
// Setuptools records
244+
private record Setuptools(
245+
@Nullable SetuptoolsPackages packages
246+
) {}
247+
248+
private record SetuptoolsPackages(
249+
@Nullable SetuptoolsFind find
250+
) {}
251+
252+
private record SetuptoolsFind(
253+
@JsonSetter(nulls = Nulls.AS_EMPTY) @Nonnull List<String> where
254+
) {
255+
SetuptoolsFind {
256+
where = where != null ? where : List.of();
257+
}
258+
}
259+
260+
// Poetry records
261+
private record Poetry(
262+
@JsonSetter(nulls = Nulls.AS_EMPTY) @Nonnull List<PoetryPackage> packages,
263+
@JsonSetter(nulls = Nulls.AS_EMPTY) @Nonnull Map<String, String> dependencies
264+
) {
265+
Poetry {
266+
packages = packages != null ? packages : List.of();
267+
dependencies = dependencies != null ? dependencies : Map.of();
268+
}
269+
}
270+
271+
private record PoetryPackage(
272+
@Nullable String include,
273+
@Nullable String from
274+
) {}
275+
276+
// Hatchling records
277+
private record Hatch(
278+
@Nullable HatchBuild build
279+
) {}
280+
281+
private record HatchBuild(
282+
@Nullable HatchTargets targets
283+
) {}
284+
285+
private record HatchTargets(
286+
@Nullable HatchWheel wheel
287+
) {}
288+
289+
private record HatchWheel(
290+
@JsonSetter(nulls = Nulls.AS_EMPTY) @Nonnull List<String> sources,
291+
@JsonSetter(nulls = Nulls.AS_EMPTY) @Nonnull List<String> packages
292+
) {
293+
HatchWheel {
294+
sources = sources != null ? sources : List.of();
295+
packages = packages != null ? packages : List.of();
296+
}
297+
}
298+
299+
// uv_build records
300+
private record Uv(
301+
@JsonProperty("build-backend") @Nullable UvBuildBackend buildBackend
302+
) {}
303+
304+
private record UvBuildBackend(
305+
@JsonProperty("module-root") @Nullable String moduleRoot
306+
) {}
307+
308+
// PDM records
309+
private record Pdm(
310+
@JsonProperty("package-dir") @Nullable String packageDir
311+
) {}
312+
313+
// Flit records
314+
private record Flit(
315+
@Nullable FlitModule module
316+
) {}
317+
318+
private record FlitModule(
319+
@Nullable String name
320+
) {}
321+
}
322+

0 commit comments

Comments
 (0)