github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/python/parse_pdm_lock_test.go (about) 1 package python 2 3 import ( 4 "context" 5 "os" 6 "testing" 7 8 "github.com/google/go-cmp/cmp" 9 "github.com/stretchr/testify/require" 10 11 "github.com/anchore/syft/syft/artifact" 12 "github.com/anchore/syft/syft/file" 13 "github.com/anchore/syft/syft/pkg" 14 "github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest" 15 ) 16 17 func TestParsePdmLock(t *testing.T) { 18 19 fixture := "test-fixtures/pdm-lock/pdm.lock" 20 locations := file.NewLocationSet(file.NewLocation(fixture)) 21 expectedPkgs := []pkg.Package{ 22 { 23 Name: "certifi", 24 Version: "2025.1.31", 25 PURL: "pkg:pypi/certifi@2025.1.31", 26 Locations: locations, 27 Language: pkg.Python, 28 Type: pkg.PythonPkg, 29 Metadata: pkg.PythonPdmLockEntry{ 30 Summary: "Python package for providing Mozilla's CA Bundle.", 31 Marker: `python_version >= "3.6"`, 32 Files: []pkg.PythonPdmFileEntry{ 33 { 34 URL: "", 35 Digest: pkg.PythonFileDigest{ 36 Algorithm: "sha256", 37 Value: "3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", 38 }, 39 }, 40 { 41 URL: "", 42 Digest: pkg.PythonFileDigest{ 43 Algorithm: "sha256", 44 Value: "ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", 45 }, 46 }, 47 }, 48 RequiresPython: ">=3.6", 49 }, 50 }, 51 { 52 Name: "chardet", 53 Version: "3.0.4", 54 PURL: "pkg:pypi/chardet@3.0.4", 55 Locations: locations, 56 Language: pkg.Python, 57 Type: pkg.PythonPkg, 58 Metadata: pkg.PythonPdmLockEntry{ 59 Summary: "Universal encoding detector for Python 2 and 3", 60 Marker: `os_name == "nt"`, 61 Files: []pkg.PythonPdmFileEntry{ 62 { 63 URL: "", 64 Digest: pkg.PythonFileDigest{ 65 Algorithm: "sha256", 66 Value: "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691", 67 }, 68 }, 69 { 70 URL: "", 71 Digest: pkg.PythonFileDigest{ 72 Algorithm: "sha256", 73 Value: "84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 74 }, 75 }, 76 }, 77 }, 78 }, 79 { 80 Name: "charset-normalizer", 81 Version: "2.0.12", 82 PURL: "pkg:pypi/charset-normalizer@2.0.12", 83 Locations: locations, 84 Language: pkg.Python, 85 Type: pkg.PythonPkg, 86 Metadata: pkg.PythonPdmLockEntry{ 87 Summary: "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet.", 88 Marker: `python_version >= "3.6"`, 89 Files: []pkg.PythonPdmFileEntry{ 90 { 91 URL: "", 92 Digest: pkg.PythonFileDigest{ 93 Algorithm: "sha256", 94 Value: "6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df", 95 }, 96 }, 97 { 98 URL: "", 99 Digest: pkg.PythonFileDigest{ 100 Algorithm: "sha256", 101 Value: "2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", 102 }, 103 }, 104 }, 105 RequiresPython: ">=3.5.0", 106 }, 107 }, 108 { 109 Name: "colorama", 110 Version: "0.3.9", 111 PURL: "pkg:pypi/colorama@0.3.9", 112 Locations: locations, 113 Language: pkg.Python, 114 Type: pkg.PythonPkg, 115 Metadata: pkg.PythonPdmLockEntry{ 116 Summary: "Cross-platform colored terminal text.", 117 Marker: `sys_platform == "win32"`, 118 Files: []pkg.PythonPdmFileEntry{ 119 { 120 URL: "", 121 Digest: pkg.PythonFileDigest{ 122 Algorithm: "sha256", 123 Value: "463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda", 124 }, 125 }, 126 { 127 URL: "", 128 Digest: pkg.PythonFileDigest{ 129 Algorithm: "sha256", 130 Value: "48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1", 131 }, 132 }, 133 }, 134 }, 135 }, 136 { 137 Name: "idna", 138 Version: "2.7", 139 PURL: "pkg:pypi/idna@2.7", 140 Locations: locations, 141 Language: pkg.Python, 142 Type: pkg.PythonPkg, 143 Metadata: pkg.PythonPdmLockEntry{ 144 Summary: "Internationalized Domain Names in Applications (IDNA)", 145 Files: []pkg.PythonPdmFileEntry{ 146 { 147 URL: "", 148 Digest: pkg.PythonFileDigest{ 149 Algorithm: "sha256", 150 Value: "156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 151 }, 152 }, 153 { 154 URL: "", 155 Digest: pkg.PythonFileDigest{ 156 Algorithm: "sha256", 157 Value: "684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16", 158 }, 159 }, 160 }, 161 }, 162 }, 163 { 164 Name: "py", 165 Version: "1.4.34", 166 PURL: "pkg:pypi/py@1.4.34", 167 Locations: locations, 168 Language: pkg.Python, 169 Type: pkg.PythonPkg, 170 Metadata: pkg.PythonPdmLockEntry{ 171 Summary: "library with cross-python path, ini-parsing, io, code, log facilities", 172 Files: []pkg.PythonPdmFileEntry{ 173 { 174 URL: "", 175 Digest: pkg.PythonFileDigest{ 176 Algorithm: "sha256", 177 Value: "2ccb79b01769d99115aa600d7eed99f524bf752bba8f041dc1c184853514655a", 178 }, 179 }, 180 { 181 URL: "", 182 Digest: pkg.PythonFileDigest{ 183 Algorithm: "sha256", 184 Value: "0f2d585d22050e90c7d293b6451c83db097df77871974d90efd5a30dc12fcde3", 185 }, 186 }, 187 }, 188 }, 189 }, 190 { 191 Name: "pytest", 192 Version: "3.2.5", 193 PURL: "pkg:pypi/pytest@3.2.5", 194 Locations: locations, 195 Language: pkg.Python, 196 Type: pkg.PythonPkg, 197 Metadata: pkg.PythonPdmLockEntry{ 198 Summary: "pytest: simple powerful testing with Python", 199 Files: []pkg.PythonPdmFileEntry{ 200 { 201 URL: "", 202 Digest: pkg.PythonFileDigest{ 203 Algorithm: "sha256", 204 Value: "6d5bd4f7113b444c55a3bbb5c738a3dd80d43563d063fc42dcb0aaefbdd78b81", 205 }, 206 }, 207 { 208 URL: "", 209 Digest: pkg.PythonFileDigest{ 210 Algorithm: "sha256", 211 Value: "241d7e7798d79192a123ceaf64c602b4d233eacf6d6e42ae27caa97f498b7dc6", 212 }, 213 }, 214 }, 215 Dependencies: []string{ 216 `argparse; python_version == "2.6"`, 217 `colorama; sys_platform == "win32"`, 218 `ordereddict; python_version == "2.6"`, 219 "py>=1.4.33", 220 "setuptools", 221 }, 222 }, 223 }, 224 { 225 Name: "requests", 226 Version: "2.27.1", 227 PURL: "pkg:pypi/requests@2.27.1", 228 Locations: locations, 229 Language: pkg.Python, 230 Type: pkg.PythonPkg, 231 Metadata: pkg.PythonPdmLockEntry{ 232 Summary: "Python HTTP for Humans.", 233 Marker: `python_version >= "3.6"`, 234 Files: []pkg.PythonPdmFileEntry{ 235 { 236 URL: "", 237 Digest: pkg.PythonFileDigest{ 238 Algorithm: "sha256", 239 Value: "f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d", 240 }, 241 }, 242 { 243 URL: "", 244 Digest: pkg.PythonFileDigest{ 245 Algorithm: "sha256", 246 Value: "68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 247 }, 248 }, 249 }, 250 RequiresPython: ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*", 251 Dependencies: []string{ 252 "certifi>=2017.4.17", 253 `chardet<5,>=3.0.2; python_version < "3"`, 254 `charset-normalizer~=2.0.0; python_version >= "3"`, 255 `idna<3,>=2.5; python_version < "3"`, 256 `idna<4,>=2.5; python_version >= "3"`, 257 "urllib3<1.27,>=1.21.1", 258 }, 259 }, 260 }, 261 { 262 Name: "setuptools", 263 Version: "39.2.0", 264 PURL: "pkg:pypi/setuptools@39.2.0", 265 Locations: locations, 266 Language: pkg.Python, 267 Type: pkg.PythonPkg, 268 Metadata: pkg.PythonPdmLockEntry{ 269 Summary: "Easily download, build, install, upgrade, and uninstall Python packages", 270 Files: []pkg.PythonPdmFileEntry{ 271 { 272 URL: "", 273 Digest: pkg.PythonFileDigest{ 274 Algorithm: "sha256", 275 Value: "f7cddbb5f5c640311eb00eab6e849f7701fa70bf6a183fc8a2c33dd1d1672fb2", 276 }, 277 }, 278 { 279 URL: "", 280 Digest: pkg.PythonFileDigest{ 281 Algorithm: "sha256", 282 Value: "8fca9275c89964f13da985c3656cb00ba029d7f3916b37990927ffdf264e7926", 283 }, 284 }, 285 }, 286 RequiresPython: ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*", 287 }, 288 }, 289 { 290 Name: "urllib3", 291 Version: "1.26.20", 292 PURL: "pkg:pypi/urllib3@1.26.20", 293 Locations: locations, 294 Language: pkg.Python, 295 Type: pkg.PythonPkg, 296 Metadata: pkg.PythonPdmLockEntry{ 297 Summary: "HTTP library with thread-safe connection pooling, file post, and more.", 298 Marker: `python_version >= "3.6"`, 299 Files: []pkg.PythonPdmFileEntry{ 300 { 301 URL: "", 302 Digest: pkg.PythonFileDigest{ 303 Algorithm: "sha256", 304 Value: "0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", 305 }, 306 }, 307 { 308 URL: "", 309 Digest: pkg.PythonFileDigest{ 310 Algorithm: "sha256", 311 Value: "40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", 312 }, 313 }, 314 }, 315 RequiresPython: "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7", 316 }, 317 }, 318 } 319 320 // Create a map for easy lookup of packages by name 321 pkgMap := make(map[string]pkg.Package) 322 for _, p := range expectedPkgs { 323 pkgMap[p.Name] = p 324 } 325 326 expectedRelationships := []artifact.Relationship{ 327 // pytest dependencies 328 { 329 From: pkgMap["colorama"], 330 To: pkgMap["pytest"], 331 Type: artifact.DependencyOfRelationship, 332 }, 333 { 334 From: pkgMap["py"], 335 To: pkgMap["pytest"], 336 Type: artifact.DependencyOfRelationship, 337 }, 338 { 339 From: pkgMap["setuptools"], 340 To: pkgMap["pytest"], 341 Type: artifact.DependencyOfRelationship, 342 }, 343 // requests dependencies 344 { 345 From: pkgMap["certifi"], 346 To: pkgMap["requests"], 347 Type: artifact.DependencyOfRelationship, 348 }, 349 { 350 From: pkgMap["chardet"], 351 To: pkgMap["requests"], 352 Type: artifact.DependencyOfRelationship, 353 }, 354 { 355 From: pkgMap["charset-normalizer"], 356 To: pkgMap["requests"], 357 Type: artifact.DependencyOfRelationship, 358 }, 359 { 360 From: pkgMap["urllib3"], 361 To: pkgMap["requests"], 362 Type: artifact.DependencyOfRelationship, 363 }, 364 { 365 From: pkgMap["idna"], 366 To: pkgMap["requests"], 367 Type: artifact.DependencyOfRelationship, 368 }, 369 } 370 371 pdmLockParser := newPdmLockParser(DefaultCatalogerConfig()) 372 pkgtest.TestFileParser(t, fixture, pdmLockParser.parsePdmLock, expectedPkgs, expectedRelationships) 373 } 374 375 func TestParsePdmLockWithLicenseEnrichment(t *testing.T) { 376 ctx := context.TODO() 377 fixture := "test-fixtures/pypi-remote/pdm.lock" 378 locations := file.NewLocationSet(file.NewLocation(fixture)) 379 mux, url, teardown := setupPypiRegistry() 380 defer teardown() 381 tests := []struct { 382 name string 383 fixture string 384 config CatalogerConfig 385 requestHandlers []handlerPath 386 expectedPackages []pkg.Package 387 }{ 388 { 389 name: "search remote licenses returns the expected licenses when search is set to true", 390 config: CatalogerConfig{SearchRemoteLicenses: true}, 391 requestHandlers: []handlerPath{ 392 { 393 path: "/certifi/2025.10.5/json", 394 handler: generateMockPypiRegistryHandler("test-fixtures/pypi-remote/registry_response.json"), 395 }, 396 }, 397 expectedPackages: []pkg.Package{ 398 { 399 Name: "certifi", 400 Version: "2025.10.5", 401 Locations: locations, 402 PURL: "pkg:pypi/certifi@2025.10.5", 403 Licenses: pkg.NewLicenseSet(pkg.NewLicenseWithContext(ctx, "MPL-2.0")), 404 Language: pkg.Python, 405 Type: pkg.PythonPkg, 406 Metadata: pkg.PythonPdmLockEntry{ 407 Summary: "Python package for providing Mozilla's CA Bundle.", 408 Marker: `python_version >= "3.7"`, 409 Files: []pkg.PythonPdmFileEntry{ 410 { 411 URL: "", 412 Digest: pkg.PythonFileDigest{ 413 Algorithm: "sha256", 414 Value: "47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", 415 }, 416 }, 417 { 418 URL: "", 419 Digest: pkg.PythonFileDigest{ 420 Algorithm: "sha256", 421 Value: "0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", 422 }, 423 }, 424 }, 425 RequiresPython: ">=3.7", 426 }, 427 }, 428 }, 429 }, 430 } 431 for _, tc := range tests { 432 t.Run(tc.name, func(t *testing.T) { 433 // set up the mock server 434 for _, handler := range tc.requestHandlers { 435 mux.HandleFunc(handler.path, handler.handler) 436 } 437 tc.config.PypiBaseURL = url 438 pdmLockParser := newPdmLockParser(tc.config) 439 pkgtest.TestFileParser(t, fixture, pdmLockParser.parsePdmLock, tc.expectedPackages, nil) 440 }) 441 } 442 } 443 444 func TestParsePdmLockWithExtras(t *testing.T) { 445 // This test verifies that PDM's multiple package entries for different extras combinations 446 // are correctly merged into a single package node in the SBOM. 447 // 448 // The fixture contains TWO [[package]] entries for "coverage": 449 // 1. Base coverage package (no extras) 450 // 2. coverage with extras = ["toml"] 451 // 452 // We should get exactly ONE coverage package in the output, with extras properly tracked. 453 454 fixture := "test-fixtures/pdm-lock-extras/pdm.lock" 455 pdmLockParser := newPdmLockParser(DefaultCatalogerConfig()) 456 457 fh, err := os.Open(fixture) 458 require.NoError(t, err) 459 defer fh.Close() 460 461 pkgs, relationships, err := pdmLockParser.parsePdmLock( 462 context.TODO(), 463 nil, 464 nil, 465 file.NewLocationReadCloser(file.NewLocation(fixture), fh), 466 ) 467 468 require.NoError(t, err) 469 470 // Verify we have the expected number of packages (NOT duplicated coverage) 471 require.Len(t, pkgs, 5, "should have exactly 5 packages: coverage, pytest, pytest-cov, tomli, uvloop") 472 473 // Find the coverage package and verify it's only present once 474 var coveragePkg *pkg.Package 475 coverageCount := 0 476 for i := range pkgs { 477 if pkgs[i].Name == "coverage" { 478 coverageCount++ 479 coveragePkg = &pkgs[i] 480 } 481 } 482 483 require.Equal(t, 1, coverageCount, "coverage should appear exactly ONCE in the package list (PDM has it twice in the lock file)") 484 require.NotNil(t, coveragePkg, "coverage package should be found") 485 486 // This test verifies file deduplication behavior! 487 // The fixture has identical files in both base and extras=["toml"] entries. 488 // After merging, the base should have Files populated, but the extras variant should NOT 489 // have Files (they're deduplicated because they're identical to base). 490 coverageMeta, ok := coveragePkg.Metadata.(pkg.PythonPdmLockEntry) 491 require.True(t, ok, "coverage metadata should be PythonPdmLockEntry") 492 493 expectedMeta := pkg.PythonPdmLockEntry{ 494 Summary: "Code coverage measurement for Python", 495 RequiresPython: ">=3.8", 496 Files: []pkg.PythonPdmFileEntry{ 497 { 498 URL: "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", 499 Digest: pkg.PythonFileDigest{ 500 Algorithm: "sha256", 501 Value: "077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7", 502 }, 503 }, 504 { 505 URL: "coverage-7.4.1.tar.gz", 506 Digest: pkg.PythonFileDigest{ 507 Algorithm: "sha256", 508 Value: "1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04", 509 }, 510 }, 511 }, 512 Extras: []pkg.PythonPdmLockExtraVariant{ 513 { 514 Extras: []string{"toml"}, 515 Dependencies: []string{ 516 "coverage==7.4.1", 517 "tomli; python_full_version <= \"3.11.0a6\"", 518 }, 519 // Files is nil/empty here because they're identical to base (deduplicated) 520 }, 521 }, 522 } 523 524 if diff := cmp.Diff(expectedMeta, coverageMeta); diff != "" { 525 t.Errorf("coverage metadata mismatch (-want +got):\n%s", diff) 526 } 527 528 // Verify relationships were created 529 require.NotEmpty(t, relationships, "relationships should be created") 530 531 // Verify pytest-cov has a relationship to coverage 532 // Build a package map for easy lookup 533 pkgMap := make(map[string]pkg.Package) 534 for _, p := range pkgs { 535 pkgMap[p.Name] = p 536 } 537 538 // Verify tomli package has marker preserved 539 var tomliPkg *pkg.Package 540 for i := range pkgs { 541 if pkgs[i].Name == "tomli" { 542 tomliPkg = &pkgs[i] 543 break 544 } 545 } 546 require.NotNil(t, tomliPkg, "tomli package should be found") 547 tomliMeta, ok := tomliPkg.Metadata.(pkg.PythonPdmLockEntry) 548 require.True(t, ok, "tomli metadata should be PythonPdmLockEntry") 549 require.Equal(t, `python_version < "3.11"`, tomliMeta.Marker, "tomli should have marker preserved") 550 551 // Verify uvloop package has complex marker preserved (multiple AND conditions, negations, mixed quotes) 552 var uvloopPkg *pkg.Package 553 for i := range pkgs { 554 if pkgs[i].Name == "uvloop" { 555 uvloopPkg = &pkgs[i] 556 break 557 } 558 } 559 require.NotNil(t, uvloopPkg, "uvloop package should be found") 560 uvloopMeta, ok := uvloopPkg.Metadata.(pkg.PythonPdmLockEntry) 561 require.True(t, ok, "uvloop metadata should be PythonPdmLockEntry") 562 require.Equal(t, `platform_python_implementation != 'PyPy' and sys_platform != 'win32' and python_version >= "3.8"`, uvloopMeta.Marker, "uvloop should have complex marker preserved exactly as-is") 563 564 var foundPytestCovToCoverage bool 565 for _, rel := range relationships { 566 toPkg, toOk := rel.To.(pkg.Package) 567 fromPkg, fromOk := rel.From.(pkg.Package) 568 if toOk && fromOk && toPkg.Name == "pytest-cov" && fromPkg.Name == "coverage" { 569 foundPytestCovToCoverage = true 570 break 571 } 572 } 573 require.True(t, foundPytestCovToCoverage, "should have a dependency relationship from coverage to pytest-cov") 574 } 575 576 func TestParsePdmLockWithSeparateFilesFixture(t *testing.T) { 577 // verify that PythonPdmLockExtraVariant metadata is properly populated when parsing PDM lock files 578 // with extras variants. The separate-files fixture contains rfc3986 with base + extras=["idna2008"] variant. 579 // 580 // The fixture contains TWO [[package]] entries for "rfc3986": 581 // 1. Base rfc3986 package (no extras, no dependencies) 582 // 2. rfc3986 with extras = ["idna2008"] and dependencies = ["idna", "rfc3986==1.5.0"] 583 // 584 // We should get exactly ONE rfc3986 package in the output, with the extras variant properly tracked 585 // in the Extras field. 586 587 fixture := "test-fixtures/pdm-lock-separate-files/pdm.lock" 588 pdmLockParser := newPdmLockParser(DefaultCatalogerConfig()) 589 590 fh, err := os.Open(fixture) 591 require.NoError(t, err) 592 defer fh.Close() 593 594 pkgs, relationships, err := pdmLockParser.parsePdmLock( 595 context.TODO(), 596 nil, 597 nil, 598 file.NewLocationReadCloser(file.NewLocation(fixture), fh), 599 ) 600 601 require.NoError(t, err) 602 603 // Find the rfc3986 package and verify it's only present once 604 var rfc3986Pkg *pkg.Package 605 rfc3986Count := 0 606 for i := range pkgs { 607 if pkgs[i].Name == "rfc3986" { 608 rfc3986Count++ 609 rfc3986Pkg = &pkgs[i] 610 } 611 } 612 613 require.Equal(t, 1, rfc3986Count) 614 require.NotNil(t, rfc3986Pkg) 615 616 require.Equal(t, "rfc3986", rfc3986Pkg.Name) 617 require.Equal(t, "1.5.0", rfc3986Pkg.Version) 618 619 rfc3986Meta, ok := rfc3986Pkg.Metadata.(pkg.PythonPdmLockEntry) 620 require.True(t, ok) 621 622 expectedMeta := pkg.PythonPdmLockEntry{ 623 Summary: "Validating URI References per RFC 3986", 624 RequiresPython: "", 625 Files: nil, // base package has no files in fixture 626 Extras: []pkg.PythonPdmLockExtraVariant{ 627 { 628 Extras: []string{"idna2008"}, 629 Dependencies: []string{ 630 "idna", 631 "rfc3986==1.5.0", 632 }, 633 Files: nil, // variant also has no files (fixture has no files for either entry) 634 }, 635 }, 636 } 637 638 if diff := cmp.Diff(expectedMeta, rfc3986Meta); diff != "" { 639 t.Errorf("rfc3986 metadata mismatch (-want +got):\n%s", diff) 640 } 641 642 require.NotEmpty(t, relationships, "relationships should be created") 643 } 644 645 func TestMergePdmLockPackagesNoBasePackage(t *testing.T) { 646 // test the edge case where only extras variants exist (no base package entry) 647 // this can happen if PDM lock file only contains package entries with extras 648 packages := []pdmLockPackage{ 649 { 650 Name: "test-package", 651 Version: "1.0.0", 652 RequiresPython: ">=3.8", 653 Summary: "Test package summary", 654 Marker: "extra == 'dev'", 655 Dependencies: []string{"pytest", "test-package==1.0.0"}, 656 Extras: []string{"dev"}, 657 Files: []pdmLockPackageFile{ 658 { 659 File: "test-package-1.0.0.tar.gz", 660 Hash: "sha256:abc123", 661 }, 662 }, 663 }, 664 { 665 Name: "test-package", 666 Version: "1.0.0", 667 RequiresPython: ">=3.8", 668 Summary: "Test package summary", 669 Marker: "extra == 'test'", 670 Dependencies: []string{"coverage", "test-package==1.0.0"}, 671 Extras: []string{"test"}, 672 Files: []pdmLockPackageFile{ 673 { 674 File: "test-package-1.0.0.tar.gz", 675 Hash: "sha256:abc123", 676 }, 677 }, 678 }, 679 } 680 681 entry := mergePdmLockPackages(packages) 682 683 // verify fallback logic: when no base package exists, first package's metadata is used 684 require.Equal(t, "Test package summary", entry.Summary) 685 require.Equal(t, ">=3.8", entry.RequiresPython) 686 require.Equal(t, []string{"pytest", "test-package==1.0.0"}, entry.Dependencies) 687 require.Equal(t, "extra == 'dev'", entry.Marker) 688 689 // verify both extras variants are present 690 require.Len(t, entry.Extras, 2) 691 require.Equal(t, []string{"dev"}, entry.Extras[0].Extras) 692 require.Equal(t, []string{"pytest", "test-package==1.0.0"}, entry.Extras[0].Dependencies) 693 require.Equal(t, []string{"test"}, entry.Extras[1].Extras) 694 require.Equal(t, []string{"coverage", "test-package==1.0.0"}, entry.Extras[1].Dependencies) 695 } 696 697 func Test_corruptPdmLock(t *testing.T) { 698 psr := newPdmLockParser(DefaultCatalogerConfig()) 699 pkgtest.NewCatalogTester(). 700 FromFile(t, "test-fixtures/glob-paths/src/pdm.lock"). 701 WithError(). 702 TestParser(t, psr.parsePdmLock) 703 }