|
| 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