diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a76821..2fd634f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`LocalPurl.Dev`** says the manifest declares a dependency only to develop the project: + `devDependencies` in `package.json` and `package-lock.json`, Maven's `test` scope, Gradle test + configurations, and the `pyproject.toml` groups and development extras. `dependencies` output + gains `"dev": true` on those entries. + +### Fixed + +- **`pyproject.toml` is read as TOML.** The line-based reader dropped a one-line + `dependencies = ["requests>=2.31"]`, invented dependencies from other strings such as + `readme = "README.md"`, misread Poetry tables, and never read `[dependency-groups]`. It now + reads `[project]` dependencies, each extra in `[project.optional-dependencies]`, each PEP 735 + group and Poetry's dependencies, dev-dependencies and groups, with the table as `Scope`, such as + `project.optional-dependencies.dev`. A malformed file is now an error instead of a partial list. +- **Development extras are dev in `poetry.lock` and `uv.lock` graphs.** An extra named for + development (`dev`, `test`, `lint`, `docs` and the like; see `pkg/dependencies/README.md`) marks + its dependencies dev, as the manifest parser does. Other extras stay runtime. + ## [0.16.0] - 2026-10-30 ### Added diff --git a/pkg/dependencies/README.md b/pkg/dependencies/README.md index 20451e8..4bda992 100644 --- a/pkg/dependencies/README.md +++ b/pkg/dependencies/README.md @@ -126,6 +126,7 @@ type LocalPurl struct { Purl string // Package URL (e.g., "pkg:npm/react@18.0.0") Requirement string // Version requirement (e.g., ">=1.0.0") Scope string // Dependency scope (e.g., "devDependencies") + Dev bool // The manifest declares it only to develop the project } ``` @@ -208,7 +209,10 @@ pkg:nuget/Newtonsoft.Json@13.0.1 - Filters out URLs, paths, and recursive dependencies - Handles operators: `==`, `>=`, `~`, `!=`, etc. - Exact versions (`==`) included in PURL, others in `Requirement` -- **pyproject.toml**: Parses dependencies from `[tool.poetry.dependencies]` section +- **pyproject.toml**: Read as TOML: `[project]` dependencies, each extra in + `[project.optional-dependencies]`, each PEP 735 `[dependency-groups]` group, and Poetry's + `dependencies`, `dev-dependencies` and groups. `Scope` is the table, such as + `project.optional-dependencies.dev`. See [Dev dependencies](#dev-dependencies) for which are dev. ### Maven Parser @@ -246,6 +250,27 @@ pkg:nuget/Newtonsoft.Json@13.0.1 - **packages.config**: Parses legacy package elements - Both extract package ID and version +### Dev dependencies + +`LocalPurl.Dev` is true when the manifest declares a dependency only to develop the project +(tests, linting, type checking, documentation, build tooling), so a consumer can tell it apart +without a lockfile: + +| Manifest | Dev | +|---|---| +| `package.json` | `devDependencies` | +| `package-lock.json` | entries marked `"dev": true` | +| `pom.xml` | `test` scope (`provided` is runtime: the platform supplies it) | +| `build.gradle` | configurations that start with `test` or contain `Test`, such as `testImplementation` and `androidTestImplementation` | +| `pyproject.toml` | every `[dependency-groups]` group, Poetry's `dev-dependencies` and every Poetry group but `main`, and every development extra | + +An extra in `[project.optional-dependencies]` is installed by any user who asks for it, so it is +dev only when its name says so: its PEP 685 normalized name, or the part before its first dash, +is one of `dev`, `devel`, `develop`, `development`, `test`, `tests`, `testing`, `lint`, +`linting`, `typing`, `mypy`, `doc`, `docs`, `coverage` or `qa`. `dev`, `Test_Integration` and +`docs-build` are dev; `socks` and `devices` are not. The `poetry.lock` and `uv.lock` resolvers +apply the same rule, so a dependency reads the same with or without a lockfile. + ## Dependency Graphs The parsers above return a flat list of PURLs per file. To know which package @@ -298,8 +323,8 @@ and edges kept, and a bad lockfile is reported on its root, never as a panic. | `pnpm-lock.yaml` (5.x, 6.x, 9.x) | lockfile only | every importer | each importer's `devDependencies` | | `Cargo.lock` | lockfile, `Cargo.toml` and workspace member manifests | `Cargo.toml` and each workspace member; a path crate that something depends on is a local node | `[dev-dependencies]`, including per target, that are not also normal or build dependencies; unknown for a root without its manifest | | `Gemfile.lock` | lockfile and `Gemfile` | `Gemfile`; `PATH` gems are local nodes | gems declared only in the `development` and `test` groups; unknown without a `Gemfile` | -| `poetry.lock` | lockfile and `pyproject.toml` | project; path dependencies are local nodes | `dev-dependencies`, every Poetry group but `main`, and `[dependency-groups]`; unknown without `pyproject.toml` | -| `uv.lock` (version 1) | lockfile only | the project and each workspace member, at its `pyproject.toml`; editable, directory and path packages are local nodes | each root's `dev-dependencies` groups; extras are runtime | +| `poetry.lock` | lockfile and `pyproject.toml` | project; path dependencies are local nodes | `dev-dependencies`, every Poetry group but `main`, every `[dependency-groups]` group, and development extras; unknown without `pyproject.toml` | +| `uv.lock` (version 1) | lockfile only | the project and each workspace member, at its `pyproject.toml`; editable, directory and path packages are local nodes | each root's `dev-dependencies` groups and development extras | | `packages.lock.json` (NuGet v1, v2) | lockfile, and the name of the project file beside it | the `.csproj`, `.fsproj` or `.vbproj` beside it, or the lockfile when there is not exactly one; every target framework is merged, and `Project` entries are local nodes | unknown: the lockfile does not record them | | `composer.lock` | lockfile and `composer.json` | project; path repository packages are local nodes | `require-dev`; unknown without `composer.json` | | `gradle.lockfile` | lockfile, and whether `build.gradle.kts` is beside it | `build.gradle`, or `build.gradle.kts` when it is the only build file; no direct members and no edges (`edges_unknown`) | unknown | diff --git a/pkg/dependencies/internal/pyscope/pyscope.go b/pkg/dependencies/internal/pyscope/pyscope.go new file mode 100644 index 0000000..5155885 --- /dev/null +++ b/pkg/dependencies/internal/pyscope/pyscope.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright (c) 2026, SCANOSS + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +// Package pyscope decides which pyproject.toml dependencies are only needed to +// develop the project. The manifest parser and the poetry.lock and uv.lock +// resolvers share it, so a dependency reads as dev with or without a lockfile. +// +// Every PEP 735 [dependency-groups] group, Poetry's dev-dependencies and every +// Poetry group but main are for development: none of them is installed with +// the package. An extra is installed by any user who asks for it, so it is +// dev only when its name says it is (DevExtra). +package pyscope + +import ( + "regexp" + "strings" +) + +// devExtraNames are the extra names the Python ecosystem uses for tests, +// linting, type checking, documentation and other development tooling. +var devExtraNames = map[string]bool{ + "dev": true, "devel": true, "develop": true, "development": true, + "test": true, "tests": true, "testing": true, + "lint": true, "linting": true, "typing": true, "mypy": true, + "doc": true, "docs": true, "coverage": true, "qa": true, +} + +var separators = regexp.MustCompile(`[-_.]+`) + +// DevExtra reports whether an extra is for development: its normalized name +// (PEP 685), or the part of it before the first dash, is a development name, +// so dev, Test, docs and test-integration are dev, and socks and devices are +// not. +func DevExtra(name string) bool { + n := separators.ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "-") + head, _, _ := strings.Cut(n, "-") + return devExtraNames[n] || devExtraNames[head] +} + +// DevPoetryGroup reports whether a Poetry dependency group is for development. +func DevPoetryGroup(name string) bool { + return name != "main" +} diff --git a/pkg/dependencies/internal/pyscope/pyscope_test.go b/pkg/dependencies/internal/pyscope/pyscope_test.go new file mode 100644 index 0000000..84259eb --- /dev/null +++ b/pkg/dependencies/internal/pyscope/pyscope_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright (c) 2026, SCANOSS + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package pyscope + +import "testing" + +func TestDevExtra(t *testing.T) { + t.Parallel() + for name, want := range map[string]bool{ + "dev": true, "Dev": true, "tests": true, "test-integration": true, "Test_Integration": true, + "docs.build": true, "lint": true, "typing": true, + "socks": false, "devices": false, "testbed": false, "all": false, "postgres": false, "": false, + } { + if got := DevExtra(name); got != want { + t.Errorf("DevExtra(%q) = %v, want %v", name, got, want) + } + } +} + +func TestDevPoetryGroup(t *testing.T) { + t.Parallel() + if DevPoetryGroup("main") { + t.Error("main is the runtime group") + } + if !DevPoetryGroup("test") { + t.Error("every group but main is for development") + } +} diff --git a/pkg/dependencies/parsers/dev_test.go b/pkg/dependencies/parsers/dev_test.go new file mode 100644 index 0000000..a46f79a --- /dev/null +++ b/pkg/dependencies/parsers/dev_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright (c) 2026, SCANOSS + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package parsers + +import ( + "maps" + "testing" +) + +// TestDevDeclarations checks that each manifest's own way of declaring a +// development-only dependency sets Dev, and that a runtime one does not. +func TestDevDeclarations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parse ParserFunc + file string + content string + want map[string]bool // purl -> Dev + }{ + { + name: "package.json devDependencies", + parse: ParsePackageJSON, + file: "package.json", + content: `{"dependencies": {"express": "^4.19.0"}, "devDependencies": {"jest": "^29.7.0"}}`, + want: map[string]bool{"pkg:npm/express@^4.19.0": false, "pkg:npm/jest@^29.7.0": true}, + }, + { + name: "package-lock.json dev entries", + parse: ParsePackageLock, + file: "package-lock.json", + content: `{"lockfileVersion": 3, "packages": { + "": {"name": "app"}, + "node_modules/express": {"version": "4.19.2"}, + "node_modules/jest": {"version": "29.7.0", "dev": true} +}}`, + want: map[string]bool{"pkg:npm/express@4.19.2": false, "pkg:npm/jest@29.7.0": true}, + }, + { + name: "pom.xml test scope", + parse: ParsePomXML, + file: "pom.xml", + content: ` + org.slf4jslf4j-api2.0.9 + junitjunit4.13.2test + javax.servletservlet-api2.5provided +`, + want: map[string]bool{ + "pkg:maven/org.slf4j/slf4j-api@2.0.9": false, + "pkg:maven/junit/junit@4.13.2": true, + "pkg:maven/javax.servlet/servlet-api@2.5": false, + }, + }, + { + name: "build.gradle test configurations", + parse: ParseBuildGradle, + file: "build.gradle", + content: `dependencies { + implementation 'com.google.guava:guava:32.1.2-jre' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation group: 'org.mockito', name: 'mockito-core', version: '5.5.0' +} +`, + want: map[string]bool{ + "pkg:maven/com.google.guava/guava@32.1.2-jre": false, + "pkg:maven/junit/junit@4.13.2": true, + "pkg:maven/org.mockito/mockito-core@5.5.0": true, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result, err := tc.parse([]byte(tc.content), tc.file) + if err != nil { + t.Fatalf("parse: %v", err) + } + got := map[string]bool{} + for _, p := range result.Purls { + got[p.Purl] = p.Dev + } + if !maps.Equal(got, tc.want) { + t.Errorf("got purl -> Dev %v, want %v", got, tc.want) + } + }) + } +} diff --git a/pkg/dependencies/parsers/gradle.go b/pkg/dependencies/parsers/gradle.go index d074b13..f6ec369 100644 --- a/pkg/dependencies/parsers/gradle.go +++ b/pkg/dependencies/parsers/gradle.go @@ -134,6 +134,7 @@ func ParseBuildGradle(fileContent []byte, filePath string) (*LocalDependency, er Purl: purl, Requirement: version, Scope: scope, + Dev: gradleTestConfiguration(scope), Line: lineNo, DeclaredText: strings.TrimSpace(originalLine), }) @@ -169,6 +170,7 @@ func ParseBuildGradle(fileContent []byte, filePath string) (*LocalDependency, er Purl: purl, Requirement: version, Scope: currentScope, + Dev: gradleTestConfiguration(currentScope), Line: depLine, DeclaredText: strings.TrimSpace(line), }) @@ -197,3 +199,9 @@ func ParseGradle(fileContent []byte, filePath string) (*LocalDependency, error) Purls: []LocalPurl{}, }, nil } + +// gradleTestConfiguration reports whether a dependency configuration only +// builds or runs tests, such as testImplementation or androidTestImplementation. +func gradleTestConfiguration(configuration string) bool { + return strings.HasPrefix(configuration, "test") || strings.Contains(configuration, "Test") +} diff --git a/pkg/dependencies/parsers/maven.go b/pkg/dependencies/parsers/maven.go index eaa290e..c878902 100644 --- a/pkg/dependencies/parsers/maven.go +++ b/pkg/dependencies/parsers/maven.go @@ -291,6 +291,7 @@ func ParsePomXML(fileContent []byte, filePath string) (*LocalDependency, error) Purl: purl, Requirement: version, Scope: scope, + Dev: scope == "test", Line: offsetToLine(fileContent, cd.startOff), DeclaredText: strings.TrimSpace(string(fileContent[cd.startOff:cd.endOff])), }) diff --git a/pkg/dependencies/parsers/npm.go b/pkg/dependencies/parsers/npm.go index a4a7929..a6e54af 100644 --- a/pkg/dependencies/parsers/npm.go +++ b/pkg/dependencies/parsers/npm.go @@ -224,6 +224,7 @@ func ParsePackageJSON(fileContent []byte, filePath string) (*LocalDependency, er Purl: purl, Requirement: e.value, Scope: scope, + Dev: scope == "devDependencies", Line: offsetToLine(fileContent, e.off), DeclaredText: lineTextAt(fileContent, e.off), }) @@ -351,6 +352,7 @@ func parsePackageLockV2TokenWalk(fileContent []byte, result *LocalDependency) (* Purl: purl, Requirement: pkg.Version, Scope: scope, + Dev: pkg.Dev, Line: line, DeclaredText: declText, }) diff --git a/pkg/dependencies/parsers/python.go b/pkg/dependencies/parsers/python.go index 1bf7759..2f01b5d 100644 --- a/pkg/dependencies/parsers/python.go +++ b/pkg/dependencies/parsers/python.go @@ -28,19 +28,18 @@ import ( "bytes" "path/filepath" "regexp" + "slices" "strings" -) -var ( - // requirementRegex matches Python requirement lines - // Example: django>=4.2.0 or requests==2.28.0 or flask - requirementRegex = regexp.MustCompile(`^(?P[-\w]+)\s*(?P[>=~!]*)\s*(?P[\d\.]*)`) + "github.com/pelletier/go-toml/v2/unstable" - // pyprojectDepsRegex matches dependency lines in pyproject.toml - // Example: "django>=4.2.0" or 'requests==2.28.0' - pyprojectDepsRegex = regexp.MustCompile(`["']([^"']+)["']`) + "github.com/scanoss/scanoss.go/pkg/dependencies/internal/pyscope" ) +// requirementRegex matches Python requirement lines +// Example: django>=4.2.0 or requests==2.28.0 or flask +var requirementRegex = regexp.MustCompile(`^(?P[-\w]+)\s*(?P[>=~!]*)\s*(?P[\d\.]*)`) + // ParseRequirementsTxt parses a requirements.txt file and extracts dependencies func ParseRequirementsTxt(fileContent []byte, filePath string) (*LocalDependency, error) { result := &LocalDependency{ @@ -87,39 +86,10 @@ func ParseRequirementsTxt(fileContent []byte, filePath string) (*LocalDependency continue } - // Parse the requirement - matches := requirementRegex.FindStringSubmatch(line) - if len(matches) >= 4 { - name := matches[1] - symbol := matches[2] - version := matches[3] - - var purl string - var requirement string - - // Build PURL based on version specifier - if symbol == "" { - // No version specified - purl = BuildPURL("pypi", "", name, "", nil) - } else if symbol == "==" && version != "" { - // Exact version - purl = BuildPURL("pypi", "", name, version, nil) - requirement = version - } else if version != "" { - // Other operators (>=, ~, !=, etc.) - purl = BuildPURL("pypi", "", name, "", nil) - requirement = symbol + version - } else { - // Just the symbol without version - purl = BuildPURL("pypi", "", name, "", nil) - } - - result.Purls = append(result.Purls, LocalPurl{ - Purl: purl, - Requirement: requirement, - Line: lineNo, - DeclaredText: strings.TrimSpace(raw), - }) + if lp, ok := pypiRequirement(line); ok { + lp.Line = lineNo + lp.DeclaredText = line + result.Purls = append(result.Purls, lp) } } @@ -130,7 +100,12 @@ func ParseRequirementsTxt(fileContent []byte, filePath string) (*LocalDependency return result, nil } -// ParsePyprojectToml parses a pyproject.toml file and extracts dependencies +// ParsePyprojectToml parses a pyproject.toml file and extracts the +// dependencies it declares: [project] dependencies, each extra in +// [project.optional-dependencies], each PEP 735 [dependency-groups] group, and +// Poetry's dependencies, dev-dependencies and groups. Scope is the TOML table +// the dependency is declared in, such as "project.optional-dependencies.dev", +// and Dev follows the rule in package pyscope. A malformed file is an error. func ParsePyprojectToml(fileContent []byte, filePath string) (*LocalDependency, error) { result := &LocalDependency{ File: filePath, @@ -138,79 +113,144 @@ func ParsePyprojectToml(fileContent []byte, filePath string) (*LocalDependency, Purls: []LocalPurl{}, } - scanner := bufio.NewScanner(bytes.NewReader(fileContent)) - inDependenciesSection := false - lineNo := 0 - - for scanner.Scan() { - lineNo++ - raw := scanner.Text() - line := strings.TrimSpace(raw) - - // Skip empty lines and comments - if line == "" || strings.HasPrefix(line, "#") { - continue + var p unstable.Parser + p.Reset(fileContent) + table := []string{} + for p.NextExpression() { + e := p.Expression() + switch e.Kind { + case unstable.Table: + table = tomlKey(e.Key()) + case unstable.ArrayTable: + table = nil // no dependency table is an array of tables + case unstable.KeyValue: + if table != nil { + collectPyproject(&p, result, append(slices.Clone(table), tomlKey(e.Key())...), e) + } } + } + if err := p.Error(); err != nil { + return nil, err + } + return result, nil +} - // Check for dependencies section - if strings.Contains(line, "dependencies") && strings.Contains(line, "[") { - inDependenciesSection = true - continue +// collectPyproject adds the dependencies value declares at the TOML key path, +// descending into inline tables such as optional-dependencies = {dev = [...]}. +func collectPyproject(p *unstable.Parser, result *LocalDependency, path []string, kv *unstable.Node) { + value := kv.Value() + add := func(lp LocalPurl, ok bool, scope string, dev bool, at *unstable.Node) { + if !ok { + return } - - // Check for end of section (next section starts with [) - if inDependenciesSection && strings.HasPrefix(line, "[") { - inDependenciesSection = false - continue + lp.Scope = scope + lp.Dev = dev + lp.Line = p.Shape(at.Raw).Start.Line + lp.DeclaredText = sourceLine(p.Data(), lp.Line) + result.Purls = append(result.Purls, lp) + } + addAll := func(scope string, dev bool) { + if value.Kind != unstable.Array { + return } + for it := value.Children(); it.Next(); { + // A table such as {include-group = "test"} names another group, + // which declares its dependencies itself. + if n := it.Node(); n.Kind == unstable.String { + lp, ok := pypiRequirement(string(n.Data)) + add(lp, ok, scope, dev, n) + } + } + } + addPoetry := func(name string, scope []string, dev bool) { + lp, ok := poetryRequirement(name, value) + add(lp, ok, strings.Join(scope, "."), dev, kv) + } + switch { + case slices.Equal(path, []string{"project", "dependencies"}): + addAll(strings.Join(path, "."), false) + case len(path) == 3 && path[0] == "project" && path[1] == "optional-dependencies": + addAll(strings.Join(path, "."), pyscope.DevExtra(path[2])) + case len(path) == 2 && path[0] == "dependency-groups": + addAll(strings.Join(path, "."), true) + case len(path) == 4 && slices.Equal(path[:3], []string{"tool", "poetry", "dependencies"}): + addPoetry(path[3], path[:3], false) + case len(path) == 4 && slices.Equal(path[:3], []string{"tool", "poetry", "dev-dependencies"}): + addPoetry(path[3], path[:3], true) + case len(path) == 6 && slices.Equal(path[:3], []string{"tool", "poetry", "group"}) && path[4] == "dependencies": + addPoetry(path[5], path[:5], pyscope.DevPoetryGroup(path[3])) + case value.Kind == unstable.InlineTable: + for it := value.Children(); it.Next(); { + member := it.Node() + collectPyproject(p, result, append(slices.Clone(path), tomlKey(member.Key())...), member) + } + } +} - // Parse dependencies within the section - if inDependenciesSection { - // Extract quoted strings; all deps on this physical line share lineNo and raw. - matches := pyprojectDepsRegex.FindAllStringSubmatch(line, -1) - for _, match := range matches { - if len(match) >= 2 { - dep := match[1] - - // Parse the dependency (similar to requirements.txt) - reqMatches := requirementRegex.FindStringSubmatch(dep) - if len(reqMatches) >= 4 { - name := reqMatches[1] - symbol := reqMatches[2] - version := reqMatches[3] - - var purl string - var requirement string - - if symbol == "" { - purl = BuildPURL("pypi", "", name, "", nil) - } else if symbol == "==" && version != "" { - purl = BuildPURL("pypi", "", name, version, nil) - requirement = version - } else if version != "" { - purl = BuildPURL("pypi", "", name, "", nil) - requirement = symbol + version - } else { - purl = BuildPURL("pypi", "", name, "", nil) - } - - result.Purls = append(result.Purls, LocalPurl{ - Purl: purl, - Requirement: requirement, - Line: lineNo, - DeclaredText: strings.TrimSpace(raw), - }) - } - } +// poetryRequirement reads a Poetry dependency, declared as name = "^1.2" or +// as a table with a version, into a purl, pinned when the version is exact. +// python is the interpreter, not a dependency. +func poetryRequirement(name string, value *unstable.Node) (LocalPurl, bool) { + if strings.EqualFold(name, "python") { + return LocalPurl{}, false + } + version := "" + switch value.Kind { + case unstable.String: + version = string(value.Data) + case unstable.InlineTable: + for it := value.Children(); it.Next(); { + field := it.Node() + if slices.Equal(tomlKey(field.Key()), []string{"version"}) && field.Value().Kind == unstable.String { + version = string(field.Value().Data) } } } + version = strings.TrimSpace(version) + if exact := strings.TrimPrefix(version, "=="); exact != "" && !strings.ContainsAny(exact, "<>=!~^*, |") { + return LocalPurl{Purl: BuildPURL("pypi", "", name, exact, nil), Requirement: exact}, true + } + if version == "*" { + version = "" + } + return LocalPurl{Purl: BuildPURL("pypi", "", name, "", nil), Requirement: version}, true +} + +// pypiRequirement reads a requirement such as requests>=2.31 into a purl, +// pinned when the requirement is an exact version. +func pypiRequirement(spec string) (LocalPurl, bool) { + m := requirementRegex.FindStringSubmatch(spec) + if len(m) < 4 { + return LocalPurl{}, false + } + name, symbol, version := m[1], m[2], m[3] + switch { + case symbol == "==" && version != "": + return LocalPurl{Purl: BuildPURL("pypi", "", name, version, nil), Requirement: version}, true + case symbol != "" && version != "": + return LocalPurl{Purl: BuildPURL("pypi", "", name, "", nil), Requirement: symbol + version}, true + default: + return LocalPurl{Purl: BuildPURL("pypi", "", name, "", nil)}, true + } +} - if err := scanner.Err(); err != nil { - return nil, err +// tomlKey is the parts of a dotted TOML key. +func tomlKey(it unstable.Iterator) []string { + var parts []string + for it.Next() { + parts = append(parts, string(it.Node().Data)) } + return parts +} - return result, nil +// sourceLine is the trimmed text of the 1-based line in data. +func sourceLine(data []byte, line int) string { + for i, l := range bytes.Split(data, []byte("\n")) { + if i+1 == line { + return strings.TrimSpace(string(l)) + } + } + return "" } // ParsePython is the main entry point for parsing Python dependency files diff --git a/pkg/dependencies/parsers/python_test.go b/pkg/dependencies/parsers/python_test.go index b90bf5e..f0c9fc8 100644 --- a/pkg/dependencies/parsers/python_test.go +++ b/pkg/dependencies/parsers/python_test.go @@ -97,52 +97,104 @@ flask==2.3.0 } } -// TestParsePyprojectTomlSourceLocation verifies Line and DeclaredText for pyproject.toml. -// For pyproject.toml, multiple quoted deps on a single line share the same Line and DeclaredText. -func TestParsePyprojectTomlSourceLocation(t *testing.T) { +func TestParsePyprojectToml(t *testing.T) { t.Parallel() tests := []struct { - name string - content string - wantCount int - wantLine int - wantLines []int // per-purl exact lines; checked when len > 0 - wantPurls []string + name string + content string + want []LocalPurl }{ { - name: "single dep per line under dependencies section (PEP 508 style)", - // line 1: [tool.poetry] - // line 2: name = "myapp" - // line 3: (blank) - // line 4: [tool.poetry.dependencies] - // line 5: dep1 = "requests>=2.28.0" - // line 6: dep2 = "flask>=2.3.0" - content: `[tool.poetry] -name = "myapp" - -[tool.poetry.dependencies] -dep1 = "requests>=2.28.0" -dep2 = "flask>=2.3.0" + name: "project dependencies and a dev extra", + content: `[project] +name = "earnie-staging-probe" +version = "0.1.0" +dependencies = ["requests>=2.31"] +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "pylint>=2.17", +] `, - wantCount: 2, - wantLines: []int{5, 6}, + want: []LocalPurl{ + {Purl: "pkg:pypi/requests", Requirement: ">=2.31", Scope: "project.dependencies", Line: 4, DeclaredText: `dependencies = ["requests>=2.31"]`}, + {Purl: "pkg:pypi/pytest", Requirement: ">=7.4", Scope: "project.optional-dependencies.dev", Dev: true, Line: 7, DeclaredText: `"pytest>=7.4",`}, + {Purl: "pkg:pypi/pylint", Requirement: ">=2.17", Scope: "project.optional-dependencies.dev", Dev: true, Line: 8, DeclaredText: `"pylint>=2.17",`}, + }, }, { - name: "multiple quoted deps on single line share same Line", - // line 1: [build-system] - // line 2: requires = ["setuptools>=40.8.0", "wheel"] - // line 3: (blank) - // line 4: [tool.poetry.dependencies] - // line 5: extras = ["requests>=2.28.0", "flask>=2.3.0"] + name: "other project strings and build requirements are not dependencies", content: `[build-system] -requires = ["setuptools>=40.8.0", "wheel"] +requires = ["setuptools>=61", "wheel"] + +[project] +name = "app" +description = "A tool" +requires-python = ">=3.9" +dependencies = [ + "django==4.2.0", "flask", +] +readme = "README.md" +`, + want: []LocalPurl{ + {Purl: "pkg:pypi/django@4.2.0", Requirement: "4.2.0", Scope: "project.dependencies", Line: 9, DeclaredText: `"django==4.2.0", "flask",`}, + {Purl: "pkg:pypi/flask", Scope: "project.dependencies", Line: 9, DeclaredText: `"django==4.2.0", "flask",`}, + }, + }, + { + name: "an extra is dev only when its name says so", + content: `[project] +name = "app" +optional-dependencies = { socks = ["PySocks"], Test_Integration = ["tox"], docs = ["sphinx"] } +`, + want: []LocalPurl{ + {Purl: "pkg:pypi/PySocks", Scope: "project.optional-dependencies.socks", Line: 3}, + {Purl: "pkg:pypi/tox", Scope: "project.optional-dependencies.Test_Integration", Dev: true, Line: 3}, + {Purl: "pkg:pypi/sphinx", Scope: "project.optional-dependencies.docs", Dev: true, Line: 3}, + }, + }, + { + name: "every PEP 735 group is dev, and an include-group adds nothing", + content: `[dependency-groups] +test = ["pytest>=8", {include-group = "typing"}] +typing = ["mypy"] +release = ["twine"] +`, + want: []LocalPurl{ + {Purl: "pkg:pypi/pytest", Requirement: ">=8", Scope: "dependency-groups.test", Dev: true, Line: 2}, + {Purl: "pkg:pypi/mypy", Scope: "dependency-groups.typing", Dev: true, Line: 3}, + {Purl: "pkg:pypi/twine", Scope: "dependency-groups.release", Dev: true, Line: 4}, + }, + }, + { + name: "poetry dependencies, dev-dependencies and groups", + content: `[tool.poetry] +name = "app" [tool.poetry.dependencies] -extras = ["requests>=2.28.0", "flask>=2.3.0"] +python = "^3.9" +requests = "^2.31" +flask = { version = "2.3.0", optional = true } +click = "*" + +[tool.poetry.dev-dependencies] +black = "23.1" + +[tool.poetry.group.main.dependencies] +attrs = ">=23" + +[tool.poetry.group.test.dependencies] +pytest = { path = "../pytest" } `, - wantCount: 2, - wantLine: 5, + want: []LocalPurl{ + {Purl: "pkg:pypi/requests", Requirement: "^2.31", Scope: "tool.poetry.dependencies", Line: 6}, + {Purl: "pkg:pypi/flask@2.3.0", Requirement: "2.3.0", Scope: "tool.poetry.dependencies", Line: 7}, + {Purl: "pkg:pypi/click", Scope: "tool.poetry.dependencies", Line: 8}, + {Purl: "pkg:pypi/black@23.1", Requirement: "23.1", Scope: "tool.poetry.dev-dependencies", Dev: true, Line: 11}, + {Purl: "pkg:pypi/attrs", Requirement: ">=23", Scope: "tool.poetry.group.main.dependencies", Line: 14}, + {Purl: "pkg:pypi/pytest", Scope: "tool.poetry.group.test.dependencies", Dev: true, Line: 17}, + }, }, } @@ -153,31 +205,25 @@ extras = ["requests>=2.28.0", "flask>=2.3.0"] if err != nil { t.Fatalf("ParsePyprojectToml error: %v", err) } - if len(result.Purls) != tc.wantCount { - t.Fatalf("got %d purls, want %d; purls: %+v", len(result.Purls), tc.wantCount, result.Purls) + if len(result.Purls) != len(tc.want) { + t.Fatalf("got %d purls, want %d; purls: %+v", len(result.Purls), len(tc.want), result.Purls) } - // All purls must have non-zero Line and non-empty DeclaredText - for i, p := range result.Purls { - if p.Line == 0 { - t.Errorf("[%d] Line is 0, want non-zero", i) - } - if p.DeclaredText == "" { - t.Errorf("[%d] DeclaredText is empty", i) - } - if tc.wantLine != 0 && p.Line != tc.wantLine { - t.Errorf("[%d] Line: got %d, want %d", i, p.Line, tc.wantLine) - } - if i < len(tc.wantLines) && p.Line != tc.wantLines[i] { - t.Errorf("[%d] Line: got %d, want %d", i, p.Line, tc.wantLines[i]) + for i, got := range result.Purls { + w := tc.want[i] + if w.DeclaredText == "" { + w.DeclaredText = got.DeclaredText } - } - // For multi-dep line case: all deps on same line share same DeclaredText - if tc.wantLine != 0 && len(result.Purls) == 2 { - if result.Purls[0].DeclaredText != result.Purls[1].DeclaredText { - t.Errorf("multi-dep: expected same DeclaredText for both; got %q vs %q", - result.Purls[0].DeclaredText, result.Purls[1].DeclaredText) + if got != w { + t.Errorf("[%d] got %+v, want %+v", i, got, w) } } }) } } + +func TestParsePyprojectTomlMalformed(t *testing.T) { + t.Parallel() + if _, err := ParsePyprojectToml([]byte("[project\ndependencies = [\"requests\"]\n"), "pyproject.toml"); err == nil { + t.Fatal("want an error for a malformed pyproject.toml") + } +} diff --git a/pkg/dependencies/parsers/types.go b/pkg/dependencies/parsers/types.go index 76f1608..75f5a74 100644 --- a/pkg/dependencies/parsers/types.go +++ b/pkg/dependencies/parsers/types.go @@ -38,6 +38,12 @@ type LocalPurl struct { // Scope is the optional dependency scope (e.g., "dependencies", "devDependencies", "test") Scope string `json:"scope,omitempty"` + // Dev is true when the manifest declares the dependency only to develop the + // project (tests, linting, documentation, build tooling): devDependencies in + // package.json, Maven's test scope, Gradle test configurations, and the + // pyproject.toml groups and extras package pyscope names. + Dev bool `json:"dev,omitempty"` + // Line is the 1-based line number of the dependency declaration in the manifest file. // Zero means the location could not be determined. Line int `json:"line,omitempty"` diff --git a/pkg/dependencies/resolve/lockfile/poetry.go b/pkg/dependencies/resolve/lockfile/poetry.go index 4e76f9f..d707082 100644 --- a/pkg/dependencies/resolve/lockfile/poetry.go +++ b/pkg/dependencies/resolve/lockfile/poetry.go @@ -31,6 +31,7 @@ import ( "github.com/pelletier/go-toml/v2" + "github.com/scanoss/scanoss.go/pkg/dependencies/internal/pyscope" "github.com/scanoss/scanoss.go/pkg/dependencies/resolve" "github.com/scanoss/scanoss.go/pkg/dependencies/resolve/internal/build" ) @@ -120,8 +121,7 @@ func pyprojectDirects(r *build.Root, manifest []byte, byName map[string][]resolv r.Declare(id, dev) } } - // Every Poetry group but main, and every PEP 735 group, is for - // development; extras are installed by users. + // Package pyscope decides which groups and extras are for development. poetry := py.Tool.Poetry type table struct { deps map[string]any @@ -129,7 +129,7 @@ func pyprojectDirects(r *build.Root, manifest []byte, byName map[string][]resolv } tables := []table{{poetry.Dependencies, false}, {poetry.DevDependencies, true}} for _, g := range slices.Sorted(maps.Keys(poetry.Group)) { - tables = append(tables, table{poetry.Group[g].Dependencies, g != "main"}) + tables = append(tables, table{poetry.Group[g].Dependencies, pyscope.DevPoetryGroup(g)}) } for _, t := range tables { for _, dep := range slices.Sorted(maps.Keys(t.deps)) { @@ -143,7 +143,7 @@ func pyprojectDirects(r *build.Root, manifest []byte, byName map[string][]resolv } for _, extra := range slices.Sorted(maps.Keys(py.Project.OptionalDependencies)) { for _, dep := range py.Project.OptionalDependencies[extra] { - declare(dep, true, false) + declare(dep, true, pyscope.DevExtra(extra)) } } for _, g := range slices.Sorted(maps.Keys(py.DependencyGroups)) { diff --git a/pkg/dependencies/resolve/lockfile/testdata/poetry/dev-scope/pyproject.toml b/pkg/dependencies/resolve/lockfile/testdata/poetry/dev-scope/pyproject.toml index 79eb465..bf78a75 100644 --- a/pkg/dependencies/resolve/lockfile/testdata/poetry/dev-scope/pyproject.toml +++ b/pkg/dependencies/resolve/lockfile/testdata/poetry/dev-scope/pyproject.toml @@ -5,6 +5,7 @@ dependencies = ["both>=1"] [project.optional-dependencies] fast = ["ujson>=5"] +lint = ["ruff"] [tool.poetry.dependencies] python = "^3.10" @@ -21,4 +22,4 @@ pytest = "^8.0" [dependency-groups] docs = ["Sphinx>=7"] -lint = ["ruff", { include-group = "docs" }] +lint = [{ include-group = "docs" }] diff --git a/pkg/dependencies/resolve/lockfile/testdata/uv/extras/graph.golden.json b/pkg/dependencies/resolve/lockfile/testdata/uv/extras/graph.golden.json index 4528d37..66907bd 100644 --- a/pkg/dependencies/resolve/lockfile/testdata/uv/extras/graph.golden.json +++ b/pkg/dependencies/resolve/lockfile/testdata/uv/extras/graph.golden.json @@ -19,6 +19,11 @@ "id": "pkg:pypi/httpx@0.28.1", "purl": "pkg:pypi/httpx", "version": "0.28.1" + }, + { + "id": "pkg:pypi/iniconfig@2.1.0", + "purl": "pkg:pypi/iniconfig", + "version": "2.1.0" } ], "roots": [ @@ -44,6 +49,11 @@ { "id": "pkg:pypi/httpx@0.28.1", "direct": true + }, + { + "id": "pkg:pypi/iniconfig@2.1.0", + "direct": true, + "dev": true } ], "edges": [ diff --git a/pkg/dependencies/resolve/lockfile/testdata/uv/extras/uv.lock b/pkg/dependencies/resolve/lockfile/testdata/uv/extras/uv.lock index 695c52c..19032ab 100644 --- a/pkg/dependencies/resolve/lockfile/testdata/uv/extras/uv.lock +++ b/pkg/dependencies/resolve/lockfile/testdata/uv/extras/uv.lock @@ -11,6 +11,9 @@ dependencies = [ ] [package.optional-dependencies] +test = [ + { name = "iniconfig" }, +] cli = [ { name = "click" }, ] @@ -63,3 +66,8 @@ wheels = [ http2 = [ { name = "h2" }, ] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } diff --git a/pkg/dependencies/resolve/lockfile/uv.go b/pkg/dependencies/resolve/lockfile/uv.go index 5446ed6..8c63c0b 100644 --- a/pkg/dependencies/resolve/lockfile/uv.go +++ b/pkg/dependencies/resolve/lockfile/uv.go @@ -31,6 +31,7 @@ import ( "github.com/pelletier/go-toml/v2" + "github.com/scanoss/scanoss.go/pkg/dependencies/internal/pyscope" "github.com/scanoss/scanoss.go/pkg/dependencies/resolve" "github.com/scanoss/scanoss.go/pkg/dependencies/resolve/internal/build" ) @@ -87,7 +88,8 @@ func (p uvPackage) dev() []uvDep { // parseUvLock reads uv.lock. The project and each workspace member is a root // at its pyproject.toml, found from its editable or virtual source, and its -// dev-dependencies groups are its dev dependencies. Editable, virtual, +// dev-dependencies groups and development extras (package pyscope) are its +// dev dependencies. Editable, virtual, // directory and path packages are local, and become nodes only when another // package depends on them. A dependency on a name locked at several versions // names the version; one that does not has an edge to each. @@ -172,9 +174,15 @@ func parseUvLock(b *build.Builder, data, _ []byte, _ map[string][]byte) { r := b.Root(p.project()) r.DevKnown = true unresolved := func(detail string) { r.Flag(resolve.ReasonUnresolvedDependency, detail) } - for _, d := range p.runtime() { + for _, d := range p.Dependencies { link(i, d, func(id resolve.NodeID) { r.Declare(id, false) }, unresolved) } + for _, extra := range slices.Sorted(maps.Keys(p.OptionalDependencies)) { + dev := pyscope.DevExtra(extra) + for _, d := range p.OptionalDependencies[extra] { + link(i, d, func(id resolve.NodeID) { r.Declare(id, dev) }, unresolved) + } + } for _, d := range p.dev() { link(i, d, func(id resolve.NodeID) { r.Declare(id, true) }, unresolved) }