github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/python/parse_requirements_test.go (about) 1 package python 2 3 import ( 4 "context" 5 "testing" 6 7 "github.com/stretchr/testify/assert" 8 9 "github.com/anchore/syft/syft/artifact" 10 "github.com/anchore/syft/syft/file" 11 "github.com/anchore/syft/syft/pkg" 12 "github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest" 13 ) 14 15 func TestParseRequirementsTxt(t *testing.T) { 16 fixture := "test-fixtures/requires/requirements.txt" 17 locations := file.NewLocationSet(file.NewLocation(fixture)) 18 19 pinnedPkgs := []pkg.Package{ 20 { 21 Name: "flask", 22 Version: "4.0.0", 23 PURL: "pkg:pypi/flask@4.0.0", 24 Locations: locations, 25 Language: pkg.Python, 26 Type: pkg.PythonPkg, 27 Metadata: pkg.PythonRequirementsEntry{ 28 Name: "flask", 29 VersionConstraint: "== 4.0.0", 30 }, 31 }, 32 { 33 Name: "foo", 34 Version: "1.0.0", 35 PURL: "pkg:pypi/foo@1.0.0", 36 Locations: locations, 37 Language: pkg.Python, 38 Type: pkg.PythonPkg, 39 Metadata: pkg.PythonRequirementsEntry{ 40 Name: "foo", 41 VersionConstraint: "== 1.0.0", 42 }, 43 }, 44 { 45 Name: "someproject", 46 Version: "5.4", 47 PURL: "pkg:pypi/someproject@5.4", 48 Locations: locations, 49 Language: pkg.Python, 50 Type: pkg.PythonPkg, 51 Metadata: pkg.PythonRequirementsEntry{ 52 Name: "SomeProject", 53 VersionConstraint: "==5.4", 54 Markers: "python_version < '3.8'", 55 }, 56 }, 57 { 58 Name: "dots-allowed", 59 Version: "1.0.0", 60 PURL: "pkg:pypi/dots-allowed@1.0.0", 61 Locations: locations, 62 Language: pkg.Python, 63 Type: pkg.PythonPkg, 64 Metadata: pkg.PythonRequirementsEntry{ 65 Name: "dots-._allowed", 66 VersionConstraint: "== 1.0.0", 67 }, 68 }, 69 { 70 Name: "argh", 71 Version: "0.26.2", 72 PURL: "pkg:pypi/argh@0.26.2", 73 Locations: locations, 74 Language: pkg.Python, 75 Type: pkg.PythonPkg, 76 Metadata: pkg.PythonRequirementsEntry{ 77 Name: "argh", 78 VersionConstraint: "==0.26.2", 79 }, 80 }, 81 { 82 Name: "argh", 83 Version: "0.26.3", 84 PURL: "pkg:pypi/argh@0.26.3", 85 Locations: locations, 86 Language: pkg.Python, 87 Type: pkg.PythonPkg, 88 Metadata: pkg.PythonRequirementsEntry{ 89 Name: "argh", 90 VersionConstraint: "==0.26.3", 91 }, 92 }, 93 { 94 Name: "celery", 95 Version: "4.4.7", 96 PURL: "pkg:pypi/celery@4.4.7", 97 Locations: locations, 98 Language: pkg.Python, 99 Type: pkg.PythonPkg, 100 Metadata: pkg.PythonRequirementsEntry{ 101 Name: "celery", 102 Extras: []string{"redis", "pytest"}, 103 VersionConstraint: "== 4.4.7", 104 }, 105 }, 106 { 107 Name: "githubsampleproject", 108 Version: "3.7.1", 109 PURL: "pkg:pypi/githubsampleproject@3.7.1", 110 Locations: locations, 111 Language: pkg.Python, 112 Type: pkg.PythonPkg, 113 Metadata: pkg.PythonRequirementsEntry{ 114 Name: "GithubSampleProject", 115 VersionConstraint: "== 3.7.1", 116 URL: "git+https://github.com/owner/repo@releases/tag/v3.7.1", 117 }, 118 }, 119 { 120 Name: "friendly-bard", 121 Version: "1.0.0", 122 PURL: "pkg:pypi/friendly-bard@1.0.0", 123 Locations: locations, 124 Language: pkg.Python, 125 Type: pkg.PythonPkg, 126 Metadata: pkg.PythonRequirementsEntry{ 127 Name: "FrIeNdLy-_-bArD", 128 VersionConstraint: "== 1.0.0", 129 }, 130 }, 131 } 132 133 var testCases = []struct { 134 name string 135 fixture string 136 cfg CatalogerConfig 137 expectedPkgs []pkg.Package 138 expectedRelationships []artifact.Relationship 139 }{ 140 { 141 name: "pinned dependencies only", 142 fixture: fixture, 143 cfg: CatalogerConfig{ 144 GuessUnpinnedRequirements: false, 145 }, 146 expectedPkgs: pinnedPkgs, 147 }, 148 { 149 name: "guess unpinned requirements (lowest version)", 150 fixture: fixture, 151 cfg: CatalogerConfig{ 152 GuessUnpinnedRequirements: true, 153 }, 154 expectedPkgs: append([]pkg.Package{ 155 { 156 Name: "mopidy-dirble", 157 Version: "1.1", 158 PURL: "pkg:pypi/mopidy-dirble@1.1", 159 Locations: locations, 160 Language: pkg.Python, 161 Type: pkg.PythonPkg, 162 Metadata: pkg.PythonRequirementsEntry{ 163 Name: "Mopidy-Dirble", 164 VersionConstraint: "~= 1.1", 165 }, 166 }, 167 { 168 Name: "sqlalchemy", 169 Version: "2.0.0", 170 PURL: "pkg:pypi/sqlalchemy@2.0.0", 171 Locations: locations, 172 Language: pkg.Python, 173 Type: pkg.PythonPkg, 174 Metadata: pkg.PythonRequirementsEntry{ 175 Name: "sqlalchemy", 176 VersionConstraint: ">= 1.0.0, <= 2.0.0, != 3.0.0, <= 3.0.0", 177 }, 178 }, 179 { 180 Name: "bar", 181 Version: "2.0.0", 182 PURL: "pkg:pypi/bar@2.0.0", 183 Locations: locations, 184 Language: pkg.Python, 185 Type: pkg.PythonPkg, 186 Metadata: pkg.PythonRequirementsEntry{ 187 Name: "bar", 188 VersionConstraint: ">= 1.0.0, <= 2.0.0, != 3.0.0, <= 3.0.0", 189 }, 190 }, 191 { 192 Name: "numpy", 193 Version: "3.4.1", 194 PURL: "pkg:pypi/numpy@3.4.1", 195 Locations: locations, 196 Language: pkg.Python, 197 Type: pkg.PythonPkg, 198 Metadata: pkg.PythonRequirementsEntry{ 199 Name: "numpy", 200 VersionConstraint: ">= 3.4.1", 201 Markers: `sys_platform == 'win32'`, 202 }, 203 }, 204 { 205 Name: "requests", 206 Version: "2.8.0", 207 PURL: "pkg:pypi/requests@2.8.0", 208 Locations: locations, 209 Language: pkg.Python, 210 Type: pkg.PythonPkg, 211 Metadata: pkg.PythonRequirementsEntry{ 212 Name: "requests", 213 Extras: []string{"security"}, 214 VersionConstraint: "== 2.8.*", 215 Markers: `python_version < "2.7" and sys_platform == "linux"`, 216 }, 217 }, 218 }, pinnedPkgs...), 219 }, 220 } 221 222 for _, tc := range testCases { 223 t.Run(tc.name, func(t *testing.T) { 224 parser := newRequirementsParser(tc.cfg) 225 pkgtest.TestFileParser(t, tc.fixture, parser.parseRequirementsTxt, tc.expectedPkgs, tc.expectedRelationships) 226 }) 227 } 228 } 229 230 func TestParseRequirementsTxtWithLicenseEnrichment(t *testing.T) { 231 ctx := context.TODO() 232 fixture := "test-fixtures/pypi-remote/requirements.txt" 233 locations := file.NewLocationSet(file.NewLocation(fixture)) 234 mux, url, teardown := setupPypiRegistry() 235 defer teardown() 236 tests := []struct { 237 name string 238 fixture string 239 config CatalogerConfig 240 requestHandlers []handlerPath 241 expectedPackages []pkg.Package 242 }{ 243 { 244 name: "search remote licenses returns the expected licenses when search is set to true", 245 config: CatalogerConfig{SearchRemoteLicenses: true}, 246 requestHandlers: []handlerPath{ 247 { 248 path: "/certifi/2025.10.5/json", 249 handler: generateMockPypiRegistryHandler("test-fixtures/pypi-remote/registry_response.json"), 250 }, 251 }, 252 expectedPackages: []pkg.Package{ 253 { 254 Name: "certifi", 255 Version: "2025.10.5", 256 Locations: locations, 257 PURL: "pkg:pypi/certifi@2025.10.5", 258 Licenses: pkg.NewLicenseSet(pkg.NewLicenseWithContext(ctx, "MPL-2.0")), 259 Language: pkg.Python, 260 Type: pkg.PythonPkg, 261 Metadata: pkg.PythonRequirementsEntry{ 262 Name: "certifi", 263 VersionConstraint: "== 2025.10.5", 264 }, 265 }, 266 }, 267 }, 268 } 269 for _, tc := range tests { 270 t.Run(tc.name, func(t *testing.T) { 271 // set up the mock server 272 for _, handler := range tc.requestHandlers { 273 mux.HandleFunc(handler.path, handler.handler) 274 } 275 tc.config.PypiBaseURL = url 276 requirementsParser := newRequirementsParser(tc.config) 277 pkgtest.TestFileParser(t, fixture, requirementsParser.parseRequirementsTxt, tc.expectedPackages, nil) 278 }) 279 } 280 } 281 282 func Test_newRequirement(t *testing.T) { 283 284 tests := []struct { 285 name string 286 raw string 287 want *unprocessedRequirement 288 }{ 289 { 290 name: "simple", 291 raw: "requests==2.8", 292 want: &unprocessedRequirement{ 293 Name: "requests", 294 VersionConstraint: "==2.8", 295 }, 296 }, 297 { 298 name: "comment + constraint", 299 raw: "Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.*", 300 want: &unprocessedRequirement{ 301 Name: "Mopidy-Dirble", 302 VersionConstraint: "~= 1.1", 303 }, 304 }, 305 { 306 name: "hashes", 307 raw: "argh==0.26.3 --hash=sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3 --hash=sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65", 308 want: &unprocessedRequirement{ 309 Name: "argh", 310 VersionConstraint: "==0.26.3", 311 Hashes: "--hash=sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3 --hash=sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65", 312 }, 313 }, 314 { 315 name: "extras", 316 raw: "celery[redis, pytest] == 4.4.7 # should remove [redis, pytest]", 317 want: &unprocessedRequirement{ 318 Name: "celery[redis, pytest]", 319 VersionConstraint: "== 4.4.7", 320 }, 321 }, 322 { 323 name: "url", 324 raw: "GithubSampleProject == 3.7.1 @ git+https://github.com/owner/repo@releases/tag/v3.7.1", 325 want: &unprocessedRequirement{ 326 Name: "GithubSampleProject", 327 VersionConstraint: "== 3.7.1", 328 URL: "git+https://github.com/owner/repo@releases/tag/v3.7.1", 329 }, 330 }, 331 { 332 name: "markers", 333 raw: "numpy >= 3.4.1 ; sys_platform == 'win32'", 334 want: &unprocessedRequirement{ 335 Name: "numpy", 336 VersionConstraint: ">= 3.4.1", 337 Markers: "sys_platform == 'win32'", 338 }, 339 }, 340 } 341 for _, tt := range tests { 342 t.Run(tt.name, func(t *testing.T) { 343 assert.Equal(t, tt.want, newRequirement(tt.raw)) 344 }) 345 } 346 } 347 348 // checkout https://www.darius.page/pipdev/ for help here! (github.com/nok/pipdev) 349 func Test_parseVersion(t *testing.T) { 350 tests := []struct { 351 name string 352 version string 353 guess bool 354 want string 355 }{ 356 { 357 name: "exact", 358 version: "1.0.0", 359 want: "", // we can only parse constraints, not assume that a single version is a pin 360 }, 361 { 362 name: "exact constraint", 363 version: " == 1.0.0 ", 364 want: "1.0.0", 365 }, 366 { 367 name: "resolve lowest, simple constraint", 368 version: " >= 1.0.0 ", 369 guess: true, 370 want: "1.0.0", 371 }, 372 { 373 name: "resolve lowest, compound constraint", 374 version: " < 2.0.0, >= 1.0.0, != 1.1.0 ", 375 guess: true, 376 want: "1.0.0", 377 }, 378 { 379 name: "resolve lowest, handle asterisk", 380 version: "==2.8.*", 381 guess: true, 382 want: "2.8.0", 383 }, 384 { 385 name: "resolve lowest, handle exceptions", 386 version: " !=4.0.2,!=4.1.0,!=4.2.0,>=4.0.1,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0", 387 guess: true, 388 want: "4.0.1", 389 }, 390 { 391 name: "resolve lowest, compatible version constraint", 392 version: "~=0.6.10", // equates to >=0.6.10, ==0.6.* 393 guess: true, 394 want: "0.6.10", 395 }, 396 { 397 name: "resolve lowest, with character in version", 398 version: "~=1.2b,<=1.3a,!=1.1,!=1.2", 399 guess: true, 400 want: "1.3a0", // note: 1.3a == 1.3a0 401 }, 402 } 403 for _, tt := range tests { 404 t.Run(tt.name, func(t *testing.T) { 405 assert.Equal(t, tt.want, parseVersion(tt.version, tt.guess)) 406 }) 407 } 408 } 409 410 func Test_corruptRequirementsTxt(t *testing.T) { 411 rp := newRequirementsParser(DefaultCatalogerConfig()) 412 pkgtest.NewCatalogTester(). 413 FromFile(t, "test-fixtures/glob-paths/src/requirements.txt"). 414 WithError(). 415 TestParser(t, rp.parseRequirementsTxt) 416 }