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  }