|
6 | 6 | "os/exec" |
7 | 7 | "path/filepath" |
8 | 8 | "strings" |
| 9 | + |
| 10 | + "github.com/BurntSushi/toml" |
9 | 11 | ) |
10 | 12 |
|
11 | 13 | // DepGraph tracks direct vs transitive dependency relationships for a manifest group. |
@@ -283,6 +285,150 @@ func (g *DepGraph) PopulateNpmLockEdges(lockFilePath string) error { |
283 | 285 | return nil |
284 | 286 | } |
285 | 287 |
|
| 288 | +// PopulatePypiLockEdges builds dependency-tree edges for a pypi manifest group |
| 289 | +// from the lock files in dir. Edge keys/values are normalised (normPypi) so the |
| 290 | +// lock, `# via`, and installed-METADATA sources all merge on one key form; |
| 291 | +// BuildDependencies resolves SBOM component names against the same normalisation. |
| 292 | +// uv.lock and pylock.toml carry an explicit dependency tree; a `pip/uv compile` |
| 293 | +// requirements.txt carries it as inverted `# via` comments. All present sources |
| 294 | +// are merged. |
| 295 | +func (g *DepGraph) PopulatePypiLockEdges(dir string) { |
| 296 | + if g == nil { |
| 297 | + return |
| 298 | + } |
| 299 | + if g.Edges == nil { |
| 300 | + g.Edges = make(map[string][]string) |
| 301 | + } |
| 302 | + |
| 303 | + // uv.lock — richest tree (explicit dependencies + optional-dependencies). |
| 304 | + if data, err := os.ReadFile(filepath.Join(dir, "uv.lock")); err == nil { |
| 305 | + var lock uvLockFile |
| 306 | + if _, derr := toml.Decode(string(data), &lock); derr == nil { |
| 307 | + for _, p := range lock.Package { |
| 308 | + var children []string |
| 309 | + for _, d := range p.Dependencies { |
| 310 | + if d.Name != "" { |
| 311 | + children = append(children, d.Name) |
| 312 | + } |
| 313 | + } |
| 314 | + for _, deps := range p.OptionalDependencies { |
| 315 | + for _, d := range deps { |
| 316 | + if d.Name != "" { |
| 317 | + children = append(children, d.Name) |
| 318 | + } |
| 319 | + } |
| 320 | + } |
| 321 | + g.addPypiEdges(normPypi(p.Name), normPypiList(children)) |
| 322 | + } |
| 323 | + } |
| 324 | + } |
| 325 | + |
| 326 | + // pylock.toml — PEP 751 per-package dependencies, when the generator emits them. |
| 327 | + if data, err := os.ReadFile(filepath.Join(dir, "pylock.toml")); err == nil { |
| 328 | + var lock pylockFile |
| 329 | + if _, derr := toml.Decode(string(data), &lock); derr == nil { |
| 330 | + for _, p := range lock.Packages { |
| 331 | + var children []string |
| 332 | + for _, d := range p.Dependencies { |
| 333 | + if d.Name != "" { |
| 334 | + children = append(children, d.Name) |
| 335 | + } |
| 336 | + } |
| 337 | + g.addPypiEdges(normPypi(p.Name), normPypiList(children)) |
| 338 | + } |
| 339 | + } |
| 340 | + } |
| 341 | + |
| 342 | + // requirements.txt — invert the `# via` comments (only tree source when no |
| 343 | + // lock file exists alongside a compiled, hashed requirements.txt). |
| 344 | + if data, err := os.ReadFile(filepath.Join(dir, "requirements.txt")); err == nil { |
| 345 | + for parent, children := range parseRequirementsViaEdges(string(data)) { |
| 346 | + g.addPypiEdges(normPypi(parent), normPypiList(children)) |
| 347 | + } |
| 348 | + } |
| 349 | +} |
| 350 | + |
| 351 | +// normPypiList normalises a list of PyPI package names (dropping empties). |
| 352 | +func normPypiList(names []string) []string { |
| 353 | + out := make([]string, 0, len(names)) |
| 354 | + for _, n := range names { |
| 355 | + if n != "" { |
| 356 | + out = append(out, normPypi(n)) |
| 357 | + } |
| 358 | + } |
| 359 | + return out |
| 360 | +} |
| 361 | + |
| 362 | +// addPypiEdges appends children under parent, de-duplicating existing edges. |
| 363 | +func (g *DepGraph) addPypiEdges(parent string, children []string) { |
| 364 | + if parent == "" || len(children) == 0 { |
| 365 | + return |
| 366 | + } |
| 367 | + existing := make(map[string]bool, len(g.Edges[parent])) |
| 368 | + for _, c := range g.Edges[parent] { |
| 369 | + existing[c] = true |
| 370 | + } |
| 371 | + for _, c := range children { |
| 372 | + if c != "" && !existing[c] { |
| 373 | + g.Edges[parent] = append(g.Edges[parent], c) |
| 374 | + existing[c] = true |
| 375 | + } |
| 376 | + } |
| 377 | +} |
| 378 | + |
| 379 | +// parseRequirementsViaEdges inverts the `# via` annotations of a compiled |
| 380 | +// requirements.txt into forward edges (parent → child). A `# via -r file` |
| 381 | +// reference is an include, not a parent package, and is skipped. |
| 382 | +func parseRequirementsViaEdges(content string) map[string][]string { |
| 383 | + edges := map[string][]string{} |
| 384 | + curName := "" |
| 385 | + curVia := false |
| 386 | + for _, ll := range joinReqContinuations(content) { |
| 387 | + line := strings.TrimSpace(ll) |
| 388 | + if line == "" { |
| 389 | + continue |
| 390 | + } |
| 391 | + if strings.HasPrefix(line, "#") { |
| 392 | + if curName == "" { |
| 393 | + continue |
| 394 | + } |
| 395 | + body := strings.TrimSpace(strings.TrimPrefix(line, "#")) |
| 396 | + if rest, ok := strings.CutPrefix(body, "via"); ok { |
| 397 | + curVia = true |
| 398 | + body = strings.TrimSpace(rest) |
| 399 | + } |
| 400 | + if !curVia || body == "" || isRequirementsInclude(body) { |
| 401 | + continue |
| 402 | + } |
| 403 | + if parent, _, _, ok := parsePEP508(body); ok && parent != "" { |
| 404 | + edges[parent] = append(edges[parent], curName) |
| 405 | + } |
| 406 | + continue |
| 407 | + } |
| 408 | + curVia = false |
| 409 | + if strings.HasPrefix(line, "-") { |
| 410 | + curName = "" |
| 411 | + continue |
| 412 | + } |
| 413 | + if ci := strings.Index(line, " #"); ci >= 0 { |
| 414 | + line = strings.TrimSpace(line[:ci]) |
| 415 | + } |
| 416 | + var specParts []string |
| 417 | + for _, tok := range strings.Fields(line) { |
| 418 | + if strings.HasPrefix(tok, "--hash=") { |
| 419 | + continue |
| 420 | + } |
| 421 | + specParts = append(specParts, tok) |
| 422 | + } |
| 423 | + if name, _, _, ok := parsePEP508(strings.Join(specParts, " ")); ok { |
| 424 | + curName = name |
| 425 | + } else { |
| 426 | + curName = "" |
| 427 | + } |
| 428 | + } |
| 429 | + return edges |
| 430 | +} |
| 431 | + |
286 | 432 | // BuildGoDepGraph correlates go.mod (direct) and go.sum (all) packages from the |
287 | 433 | // same directory to determine which dependencies are direct vs transitive. |
288 | 434 | func BuildGoDepGraph(goModPkgs, goSumPkgs []ScopedPackage) *DepGraph { |
@@ -425,9 +571,9 @@ func BuildManifestGroups(filePackages map[string][]ScopedPackage, fileEcosystems |
425 | 571 | for relPath, pkgs := range gd.files { |
426 | 572 | base := strings.ToLower(filepath.Base(relPath)) |
427 | 573 | switch base { |
428 | | - case "pyproject.toml": |
429 | | - directPkgs = pkgs |
430 | | - case "uv.lock", "pipfile.lock", "poetry.lock", "requirements.txt": |
| 574 | + case "pyproject.toml", "requirements.in": |
| 575 | + directPkgs = append(directPkgs, pkgs...) |
| 576 | + case "uv.lock", "pipfile.lock", "poetry.lock", "pylock.toml", "requirements.txt": |
431 | 577 | lockPkgs = append(lockPkgs, pkgs...) |
432 | 578 | } |
433 | 579 | } |
|
0 commit comments