feat(python): add pylock.toml (PEP 751) parser (#9632)

Co-authored-by: DmitriyLewen <dmitriy.lewen@smartforce.io>
This commit is contained in:
Ashwani Kumar Kamal
2026-01-30 16:31:47 +05:30
committed by GitHub
parent cc64eebbd0
commit 1a72b326bb
5 changed files with 311 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
package pylock
import (
"sort"
"github.com/BurntSushi/toml"
"github.com/samber/lo"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/dependency"
"github.com/aquasecurity/trivy/pkg/dependency/parser/python"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
xio "github.com/aquasecurity/trivy/pkg/x/io"
)
type Pylock struct {
Packages []Package `toml:"packages"`
}
type Package struct {
Name string `toml:"name"`
Version string `toml:"version"`
Dependencies []Dependency `toml:"dependencies"`
}
type Dependency struct {
Name string `toml:"name"`
Version string `toml:"version"`
}
// Parser parses pylock.toml defined in PEP 751.
// https://peps.python.org/pep-0751
type Parser struct{}
func NewParser() *Parser {
return &Parser{}
}
func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, error) {
var lock Pylock
if _, err := toml.NewDecoder(r).Decode(&lock); err != nil {
return nil, nil, xerrors.Errorf("failed to decode pylock.toml: %w", err)
}
pkgs := make(map[string]ftypes.Package)
deps := make(map[string][]string)
for _, pkg := range lock.Packages {
normalizedPkgName := python.NormalizePkgName(pkg.Name, true)
pkgID := packageID(normalizedPkgName, pkg.Version)
pkgs[pkgID] = ftypes.Package{
ID: pkgID,
Name: normalizedPkgName,
Version: pkg.Version,
}
var dependsOn []string
for _, dep := range pkg.Dependencies {
depName := python.NormalizePkgName(dep.Name, true)
depID := packageID(depName, dep.Version)
dependsOn = append(dependsOn, depID)
}
if len(dependsOn) > 0 {
sort.Strings(dependsOn)
deps[pkgID] = dependsOn
}
}
depSlice := lo.MapToSlice(deps, func(pkgID string, dependsOn []string) ftypes.Dependency {
if _, ok := pkgs[pkgID]; !ok {
return ftypes.Dependency{}
}
// Filter out dependencies that are not in the package list
var dependsOnIDs []string
for _, depID := range dependsOn {
if _, ok := pkgs[depID]; ok {
dependsOnIDs = append(dependsOnIDs, depID)
}
}
return ftypes.Dependency{
ID: pkgID,
DependsOn: dependsOnIDs,
}
})
pkgSlice := lo.Values(pkgs)
sort.Sort(ftypes.Packages(pkgSlice))
sort.Sort(ftypes.Dependencies(depSlice))
return pkgSlice, depSlice, nil
}
func packageID(name, ver string) string {
return dependency.ID(ftypes.PyLock, name, ver)
}

View File

@@ -0,0 +1,102 @@
package pylock_test
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/aquasecurity/trivy/pkg/dependency/parser/python/pylock"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
)
func TestParser_Parse(t *testing.T) {
tests := []struct {
name string
file string
wantPkgs []ftypes.Package
wantDeps []ftypes.Dependency
wantErr assert.ErrorAssertionFunc
}{
{
name: "happy path",
file: "testdata/pylock.toml",
wantPkgs: []ftypes.Package{
{
ID: "attrs@25.4.0",
Name: "attrs",
Version: "25.4.0",
},
{
ID: "certifi@2025.10.5",
Name: "certifi",
Version: "2025.10.5",
},
{
ID: "charset-normalizer@3.4.3",
Name: "charset-normalizer",
Version: "3.4.3",
},
{
ID: "ham@3.0.0",
Name: "ham",
Version: "3.0.0",
},
{
ID: "idna@3.10",
Name: "idna",
Version: "3.10",
},
{
ID: "requests@2.32.5",
Name: "requests",
Version: "2.32.5",
},
{
ID: "spam@1.0.0",
Name: "spam",
Version: "1.0.0",
},
{
ID: "spam@1.1.0",
Name: "spam",
Version: "1.1.0",
},
{
ID: "urllib3@2.5.0",
Name: "urllib3",
Version: "2.5.0",
},
},
wantDeps: []ftypes.Dependency{
{
ID: "ham@3.0.0",
DependsOn: []string{"spam@1.1.0"},
},
},
wantErr: assert.NoError,
},
{
name: "sad path",
file: "testdata/sad.toml",
wantErr: assert.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, err := os.Open(tt.file)
require.NoError(t, err)
defer f.Close()
p := pylock.NewParser()
gotPkgs, gotDeps, err := p.Parse(f)
if !tt.wantErr(t, err, fmt.Sprintf("Parse(%v)", tt.file)) {
return
}
assert.Equalf(t, tt.wantPkgs, gotPkgs, "Parse(%v)", tt.file)
assert.Equalf(t, tt.wantDeps, gotDeps, "Parse(%v)", tt.file)
})
}
}

View File

@@ -0,0 +1,109 @@
lock-version = "1.0"
created-by = "pip"
[[packages]]
name = "attrs"
version = "25.4.0"
[[packages.wheels]]
name = "attrs-25.4.0-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"
[[packages]]
name = "certifi"
version = "2025.10.5"
[[packages.wheels]]
name = "certifi-2025.10.5-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"
[[packages]]
name = "charset-normalizer"
version = "3.4.3"
[[packages.wheels]]
name = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl"
[packages.wheels.hashes]
sha256 = "0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"
[[packages]]
name = "idna"
version = "3.10"
[[packages.wheels]]
name = "idna-3.10-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
[[packages]]
name = "requests"
version = "2.32.5"
[[packages.wheels]]
name = "requests-2.32.5-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"
[[packages]]
name = "urllib3"
version = "2.5.0"
[[packages.wheels]]
name = "urllib3-2.5.0-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"
# Packages with same name but different versions for different environments
[[packages]]
name = "spam"
version = "1.0.0"
marker = "sys_platform == 'linux'"
[[packages.wheels]]
name = "spam-1.0.0-py3-none-any.whl"
url = "https://example.invalid/files/spam-1.0.0-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
[[packages]]
name = "spam"
version = "1.1.0"
marker = "sys_platform == 'win32'"
[[packages.wheels]]
name = "spam-1.1.0-py3-none-any.whl"
url = "https://example.invalid/files/spam-1.1.0-py3-none-any.whl"
[packages.wheels.hashes]
sha256 = "feebdaedfeebdaedfeebdaedfeebdaedfeebdaedfeebdaedfeebdaedfeebdaed"
[[packages]]
name = "ham"
version = "3.0.0"
marker = "sys_platform == 'linux'"
dependencies = [
{ name = "spam", version = "1.0.0" }
]
[[packages]]
name = "ham"
version = "3.0.0"
marker = "sys_platform == 'win32'"
dependencies = [
{ name = "spam", version = "1.1.0" }
]

View File

@@ -0,0 +1 @@
[

View File

@@ -214,6 +214,7 @@ const (
PipfileLock = "Pipfile.lock"
PoetryLock = "poetry.lock"
UvLock = "uv.lock"
PyLock = "pylock.toml"
GemfileLock = "Gemfile.lock"