github.com/anchore/syft@v1.4.2-0.20240516191711-1bec1fc5d397/syft/pkg/cataloger/python/parse_requirements_test.go (about)

     1  package python
     2  
     3  import (
     4  	"testing"
     5  
     6  	"github.com/stretchr/testify/assert"
     7  
     8  	"github.com/anchore/syft/syft/artifact"
     9  	"github.com/anchore/syft/syft/file"
    10  	"github.com/anchore/syft/syft/pkg"
    11  	"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
    12  )
    13  
    14  func TestParseRequirementsTxt(t *testing.T) {
    15  	fixture := "test-fixtures/requires/requirements.txt"
    16  	locations := file.NewLocationSet(file.NewLocation(fixture))
    17  
    18  	pinnedPkgs := []pkg.Package{
    19  		{
    20  			Name:      "flask",
    21  			Version:   "4.0.0",
    22  			PURL:      "pkg:pypi/flask@4.0.0",
    23  			Locations: locations,
    24  			Language:  pkg.Python,
    25  			Type:      pkg.PythonPkg,
    26  			Metadata: pkg.PythonRequirementsEntry{
    27  				Name:              "flask",
    28  				VersionConstraint: "== 4.0.0",
    29  			},
    30  		},
    31  		{
    32  			Name:      "foo",
    33  			Version:   "1.0.0",
    34  			PURL:      "pkg:pypi/foo@1.0.0",
    35  			Locations: locations,
    36  			Language:  pkg.Python,
    37  			Type:      pkg.PythonPkg,
    38  			Metadata: pkg.PythonRequirementsEntry{
    39  				Name:              "foo",
    40  				VersionConstraint: "== 1.0.0",
    41  			},
    42  		},
    43  		{
    44  			Name:      "SomeProject",
    45  			Version:   "5.4",
    46  			PURL:      "pkg:pypi/SomeProject@5.4",
    47  			Locations: locations,
    48  			Language:  pkg.Python,
    49  			Type:      pkg.PythonPkg,
    50  			Metadata: pkg.PythonRequirementsEntry{
    51  				Name:              "SomeProject",
    52  				VersionConstraint: "==5.4",
    53  				Markers:           "python_version < '3.8'",
    54  			},
    55  		},
    56  		{
    57  			Name:      "argh",
    58  			Version:   "0.26.2",
    59  			PURL:      "pkg:pypi/argh@0.26.2",
    60  			Locations: locations,
    61  			Language:  pkg.Python,
    62  			Type:      pkg.PythonPkg,
    63  			Metadata: pkg.PythonRequirementsEntry{
    64  				Name:              "argh",
    65  				VersionConstraint: "==0.26.2",
    66  			},
    67  		},
    68  		{
    69  			Name:      "argh",
    70  			Version:   "0.26.3",
    71  			PURL:      "pkg:pypi/argh@0.26.3",
    72  			Locations: locations,
    73  			Language:  pkg.Python,
    74  			Type:      pkg.PythonPkg,
    75  			Metadata: pkg.PythonRequirementsEntry{
    76  				Name:              "argh",
    77  				VersionConstraint: "==0.26.3",
    78  			},
    79  		},
    80  		{
    81  			Name:      "celery",
    82  			Version:   "4.4.7",
    83  			PURL:      "pkg:pypi/celery@4.4.7",
    84  			Locations: locations,
    85  			Language:  pkg.Python,
    86  			Type:      pkg.PythonPkg,
    87  			Metadata: pkg.PythonRequirementsEntry{
    88  				Name:              "celery",
    89  				Extras:            []string{"redis", "pytest"},
    90  				VersionConstraint: "== 4.4.7",
    91  			},
    92  		},
    93  		{
    94  			Name:      "GithubSampleProject",
    95  			Version:   "3.7.1",
    96  			PURL:      "pkg:pypi/GithubSampleProject@3.7.1",
    97  			Locations: locations,
    98  			Language:  pkg.Python,
    99  			Type:      pkg.PythonPkg,
   100  			Metadata: pkg.PythonRequirementsEntry{
   101  				Name:              "GithubSampleProject",
   102  				VersionConstraint: "== 3.7.1",
   103  				URL:               "git+https://github.com/owner/repo@releases/tag/v3.7.1",
   104  			},
   105  		},
   106  	}
   107  
   108  	var testCases = []struct {
   109  		name                  string
   110  		fixture               string
   111  		cfg                   CatalogerConfig
   112  		expectedPkgs          []pkg.Package
   113  		expectedRelationships []artifact.Relationship
   114  	}{
   115  		{
   116  			name:    "pinned dependencies only",
   117  			fixture: fixture,
   118  			cfg: CatalogerConfig{
   119  				GuessUnpinnedRequirements: false,
   120  			},
   121  			expectedPkgs: pinnedPkgs,
   122  		},
   123  		{
   124  			name:    "guess unpinned requirements (lowest version)",
   125  			fixture: fixture,
   126  			cfg: CatalogerConfig{
   127  				GuessUnpinnedRequirements: true,
   128  			},
   129  			expectedPkgs: append([]pkg.Package{
   130  				{
   131  					Name:      "Mopidy-Dirble",
   132  					Version:   "1.1",
   133  					PURL:      "pkg:pypi/Mopidy-Dirble@1.1",
   134  					Locations: locations,
   135  					Language:  pkg.Python,
   136  					Type:      pkg.PythonPkg,
   137  					Metadata: pkg.PythonRequirementsEntry{
   138  						Name:              "Mopidy-Dirble",
   139  						VersionConstraint: "~= 1.1",
   140  					},
   141  				},
   142  				{
   143  					Name:      "sqlalchemy",
   144  					Version:   "2.0.0",
   145  					PURL:      "pkg:pypi/sqlalchemy@2.0.0",
   146  					Locations: locations,
   147  					Language:  pkg.Python,
   148  					Type:      pkg.PythonPkg,
   149  					Metadata: pkg.PythonRequirementsEntry{
   150  						Name:              "sqlalchemy",
   151  						VersionConstraint: ">= 1.0.0, <= 2.0.0, != 3.0.0, <= 3.0.0",
   152  					},
   153  				},
   154  				{
   155  					Name:      "bar",
   156  					Version:   "2.0.0",
   157  					PURL:      "pkg:pypi/bar@2.0.0",
   158  					Locations: locations,
   159  					Language:  pkg.Python,
   160  					Type:      pkg.PythonPkg,
   161  					Metadata: pkg.PythonRequirementsEntry{
   162  						Name:              "bar",
   163  						VersionConstraint: ">= 1.0.0, <= 2.0.0, != 3.0.0, <= 3.0.0",
   164  					},
   165  				},
   166  				{
   167  					Name:      "numpy",
   168  					Version:   "3.4.1",
   169  					PURL:      "pkg:pypi/numpy@3.4.1",
   170  					Locations: locations,
   171  					Language:  pkg.Python,
   172  					Type:      pkg.PythonPkg,
   173  					Metadata: pkg.PythonRequirementsEntry{
   174  						Name:              "numpy",
   175  						VersionConstraint: ">= 3.4.1",
   176  						Markers:           `sys_platform == 'win32'`,
   177  					},
   178  				},
   179  				{
   180  					Name:      "requests",
   181  					Version:   "2.8.0",
   182  					PURL:      "pkg:pypi/requests@2.8.0",
   183  					Locations: locations,
   184  					Language:  pkg.Python,
   185  					Type:      pkg.PythonPkg,
   186  					Metadata: pkg.PythonRequirementsEntry{
   187  						Name:              "requests",
   188  						Extras:            []string{"security"},
   189  						VersionConstraint: "== 2.8.*",
   190  						Markers:           `python_version < "2.7" and sys_platform == "linux"`,
   191  					},
   192  				},
   193  			}, pinnedPkgs...),
   194  		},
   195  	}
   196  
   197  	for _, tc := range testCases {
   198  		t.Run(tc.name, func(t *testing.T) {
   199  			parser := newRequirementsParser(tc.cfg)
   200  			pkgtest.TestFileParser(t, tc.fixture, parser.parseRequirementsTxt, tc.expectedPkgs, tc.expectedRelationships)
   201  		})
   202  	}
   203  }
   204  
   205  func Test_newRequirement(t *testing.T) {
   206  
   207  	tests := []struct {
   208  		name string
   209  		raw  string
   210  		want *unprocessedRequirement
   211  	}{
   212  		{
   213  			name: "simple",
   214  			raw:  "requests==2.8",
   215  			want: &unprocessedRequirement{
   216  				Name:              "requests",
   217  				VersionConstraint: "==2.8",
   218  			},
   219  		},
   220  		{
   221  			name: "comment + constraint",
   222  			raw:  "Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.*",
   223  			want: &unprocessedRequirement{
   224  				Name:              "Mopidy-Dirble",
   225  				VersionConstraint: "~= 1.1",
   226  			},
   227  		},
   228  		{
   229  			name: "hashes",
   230  			raw:  "argh==0.26.3 --hash=sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3 --hash=sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65",
   231  			want: &unprocessedRequirement{
   232  				Name:              "argh",
   233  				VersionConstraint: "==0.26.3",
   234  				Hashes:            "--hash=sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3 --hash=sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65",
   235  			},
   236  		},
   237  		{
   238  			name: "extras",
   239  			raw:  "celery[redis, pytest] == 4.4.7 # should remove [redis, pytest]",
   240  			want: &unprocessedRequirement{
   241  				Name:              "celery[redis, pytest]",
   242  				VersionConstraint: "== 4.4.7",
   243  			},
   244  		},
   245  		{
   246  			name: "url",
   247  			raw:  "GithubSampleProject == 3.7.1 @ git+https://github.com/owner/repo@releases/tag/v3.7.1",
   248  			want: &unprocessedRequirement{
   249  				Name:              "GithubSampleProject",
   250  				VersionConstraint: "== 3.7.1",
   251  				URL:               "git+https://github.com/owner/repo@releases/tag/v3.7.1",
   252  			},
   253  		},
   254  		{
   255  			name: "markers",
   256  			raw:  "numpy >= 3.4.1 ; sys_platform == 'win32'",
   257  			want: &unprocessedRequirement{
   258  				Name:              "numpy",
   259  				VersionConstraint: ">= 3.4.1",
   260  				Markers:           "sys_platform == 'win32'",
   261  			},
   262  		},
   263  	}
   264  	for _, tt := range tests {
   265  		t.Run(tt.name, func(t *testing.T) {
   266  			assert.Equal(t, tt.want, newRequirement(tt.raw))
   267  		})
   268  	}
   269  }
   270  
   271  // checkout https://www.darius.page/pipdev/ for help here! (github.com/nok/pipdev)
   272  func Test_parseVersion(t *testing.T) {
   273  	tests := []struct {
   274  		name    string
   275  		version string
   276  		guess   bool
   277  		want    string
   278  	}{
   279  		{
   280  			name:    "exact",
   281  			version: "1.0.0",
   282  			want:    "", // we can only parse constraints, not assume that a single version is a pin
   283  		},
   284  		{
   285  			name:    "exact constraint",
   286  			version: " == 1.0.0 ",
   287  			want:    "1.0.0",
   288  		},
   289  		{
   290  			name:    "resolve lowest, simple constraint",
   291  			version: " >= 1.0.0 ",
   292  			guess:   true,
   293  			want:    "1.0.0",
   294  		},
   295  		{
   296  			name:    "resolve lowest, compound constraint",
   297  			version: "  < 2.0.0,  >= 1.0.0, != 1.1.0 ",
   298  			guess:   true,
   299  			want:    "1.0.0",
   300  		},
   301  		{
   302  			name:    "resolve lowest, handle asterisk",
   303  			version: "==2.8.*",
   304  			guess:   true,
   305  			want:    "2.8.0",
   306  		},
   307  		{
   308  			name:    "resolve lowest, handle exceptions",
   309  			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",
   310  			guess:   true,
   311  			want:    "4.0.1",
   312  		},
   313  		{
   314  			name:    "resolve lowest, compatible version constraint",
   315  			version: "~=0.6.10", // equates to >=0.6.10, ==0.6.*
   316  			guess:   true,
   317  			want:    "0.6.10",
   318  		},
   319  		{
   320  			name:    "resolve lowest, with character in version",
   321  			version: "~=1.2b,<=1.3a,!=1.1,!=1.2",
   322  			guess:   true,
   323  			want:    "1.3a0", // note: 1.3a == 1.3a0
   324  		},
   325  	}
   326  	for _, tt := range tests {
   327  		t.Run(tt.name, func(t *testing.T) {
   328  			assert.Equal(t, tt.want, parseVersion(tt.version, tt.guess))
   329  		})
   330  	}
   331  }