Skip to content

Commit

Permalink
feat: support parsing uv.lock files
Browse files Browse the repository at this point in the history
  • Loading branch information
G-Rath committed Feb 5, 2025
1 parent 1a20a53 commit afe5e1d
Show file tree
Hide file tree
Showing 15 changed files with 587 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ The detector supports parsing the following lockfiles:
| `gradle.lockfile` | `CRAN` | `renv` |
| `mix.lock` | `Hex` | `mix` |
| `poetry.lock` | `PyPI` | `poetry` |
| `uv.lock` | `PyPI` | `uv` |
| `Pipfile.lock` | `PyPI` | `pipenv` |
| `pdm.lock` | `PyPI` | `pdm` |
| `pubspec.lock` | `Pub` | `pub` |
Expand Down
1 change: 1 addition & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ func TestRun(t *testing.T) {
pubspec.lock
renv.lock
requirements.txt
uv.lock
yarn.lock
csv-file
csv-row
Expand Down
4 changes: 2 additions & 2 deletions pkg/lockfile/ecosystems_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ func TestKnownEcosystems(t *testing.T) {
expectedCount := numberOfLockfileParsers(t)

// - npm, yarn, bun, and pnpm,
// - pip, poetry, pdm and pipenv,
// - pip, poetry, uv, pdm and pipenv,
// - maven and gradle,
// all use the same ecosystem so "ignore" those parsers in the count
expectedCount -= 7
expectedCount -= 8

ecosystems := lockfile.KnownEcosystems()

Expand Down
Empty file.
220 changes: 220 additions & 0 deletions pkg/lockfile/fixtures/uv/grouped-packages.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions pkg/lockfile/fixtures/uv/no-dependencies.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version = 1
requires-python = ">=3.10"

[[package]]
name = "uv-lockfiles"
version = "0.1.0"
source = { virtual = "." }
2 changes: 2 additions & 0 deletions pkg/lockfile/fixtures/uv/no-packages.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
version = 1
requires-python = ">=3.10"
1 change: 1 addition & 0 deletions pkg/lockfile/fixtures/uv/not-toml.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
this is not valid toml! (I think)
22 changes: 22 additions & 0 deletions pkg/lockfile/fixtures/uv/one-package.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
version = 1
requires-python = ">=3.10"

[[package]]
name = "emoji"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/64/812d7e2ae0ac2ade0d6583f911f99240c80f700afbe8391df10e547f564d/emoji-2.14.0.tar.gz", hash = "sha256:f68ac28915a2221667cddb3e6c589303c3c6954c6c5af6fefaec7f9bdf72fdca", size = 593932 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/56/4ddf8b36aa4b52077045b17ffb8958f3360b250df4143d1482d9d5bb54d5/emoji-2.14.0-py3-none-any.whl", hash = "sha256:fcc936bf374b1aec67dda5303ae99710ba88cc9cdce2d1a71c5f2204e6d78799", size = 586897 },
]

[[package]]
name = "uv-lockfiles"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "emoji" },
]

[package.metadata]
requires-dist = [{ name = "emoji" }]
18 changes: 18 additions & 0 deletions pkg/lockfile/fixtures/uv/source-git.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version = 1
requires-python = ">=3.10"

[[package]]
name = "ruff"
version = "0.8.1"
source = { git = "https://github.com/astral-sh/ruff#84748be16341b76e073d117329f7f5f4ee2941ad" }

[[package]]
name = "uv-lockfiles"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "ruff" },
]

[package.metadata]
requires-dist = [{ name = "ruff", git = "https://github.com/astral-sh/ruff" }]
40 changes: 40 additions & 0 deletions pkg/lockfile/fixtures/uv/two-packages.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
version = 1
requires-python = ">=3.10"

[[package]]
name = "emoji"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/64/812d7e2ae0ac2ade0d6583f911f99240c80f700afbe8391df10e547f564d/emoji-2.14.0.tar.gz", hash = "sha256:f68ac28915a2221667cddb3e6c589303c3c6954c6c5af6fefaec7f9bdf72fdca", size = 593932 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/56/4ddf8b36aa4b52077045b17ffb8958f3360b250df4143d1482d9d5bb54d5/emoji-2.14.0-py3-none-any.whl", hash = "sha256:fcc936bf374b1aec67dda5303ae99710ba88cc9cdce2d1a71c5f2204e6d78799", size = 586897 },
]

[[package]]
name = "protobuf"
version = "4.25.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/67/dd/48d5fdb68ec74d70fabcc252e434492e56f70944d9f17b6a15e3746d2295/protobuf-4.25.5.tar.gz", hash = "sha256:7f8249476b4a9473645db7f8ab42b02fe1488cbe5fb72fddd445e0665afd8584", size = 380315 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/35/1b3c5a5e6107859c4ca902f4fbb762e48599b78129a05d20684fef4a4d04/protobuf-4.25.5-cp310-abi3-win32.whl", hash = "sha256:5e61fd921603f58d2f5acb2806a929b4675f8874ff5f330b7d6f7e2e784bbcd8", size = 392457 },
{ url = "https://files.pythonhosted.org/packages/a7/ad/bf3f358e90b7e70bf7fb520702cb15307ef268262292d3bdb16ad8ebc815/protobuf-4.25.5-cp310-abi3-win_amd64.whl", hash = "sha256:4be0571adcbe712b282a330c6e89eae24281344429ae95c6d85e79e84780f5ea", size = 413449 },
{ url = "https://files.pythonhosted.org/packages/51/49/d110f0a43beb365758a252203c43eaaad169fe7749da918869a8c991f726/protobuf-4.25.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:b2fde3d805354df675ea4c7c6338c1aecd254dfc9925e88c6d31a2bcb97eb173", size = 394248 },
{ url = "https://files.pythonhosted.org/packages/c6/ab/0f384ca0bc6054b1a7b6009000ab75d28a5506e4459378b81280ae7fd358/protobuf-4.25.5-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:919ad92d9b0310070f8356c24b855c98df2b8bd207ebc1c0c6fcc9ab1e007f3d", size = 293717 },
{ url = "https://files.pythonhosted.org/packages/05/a6/094a2640be576d760baa34c902dcb8199d89bce9ed7dd7a6af74dcbbd62d/protobuf-4.25.5-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fe14e16c22be926d3abfcb500e60cab068baf10b542b8c858fa27e098123e331", size = 294635 },
{ url = "https://files.pythonhosted.org/packages/33/90/f198a61df8381fb43ae0fe81b3d2718e8dcc51ae8502c7657ab9381fbc4f/protobuf-4.25.5-py3-none-any.whl", hash = "sha256:0aebecb809cae990f8129ada5ca273d9d670b76d9bfc9b1809f0a9c02b7dbf41", size = 156467 },
]

[[package]]
name = "uv-lockfiles"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "emoji" },
{ name = "protobuf" },
]

[package.metadata]
requires-dist = [
{ name = "emoji" },
{ name = "protobuf", specifier = ">=3.19.0,<5.0.0.dev0" },
]
72 changes: 72 additions & 0 deletions pkg/lockfile/parse-uv-lock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package lockfile

import (
"fmt"
"os"
"strings"

"github.com/BurntSushi/toml"
)

type UvLockPackageSource struct {
Virtual string `toml:"virtual"`
Git string `toml:"git"`
}

type UvLockPackage struct {
Name string `toml:"name"`
Version string `toml:"version"`
Source UvLockPackageSource `toml:"source"`

// uv stores "groups" as a table under "package" after all the packages, which due
// to how TOML works means it ends up being a property on the last package, even
// through in this context it's a global property rather than being per-package
Groups map[string][]UvOptionalDependency `toml:"optional-dependencies"`
}

type UvOptionalDependency struct {
Name string `toml:"name"`
}
type UvLockFile struct {
Version int `toml:"version"`
Packages []UvLockPackage `toml:"package"`
}

const UvEcosystem = PipEcosystem

func ParseUvLock(pathToLockfile string) ([]PackageDetails, error) {
var parsedLockfile *UvLockFile

lockfileContents, err := os.ReadFile(pathToLockfile)

if err != nil {
return []PackageDetails{}, fmt.Errorf("could not read %s: %w", pathToLockfile, err)
}

err = toml.Unmarshal(lockfileContents, &parsedLockfile)

if err != nil {
return []PackageDetails{}, fmt.Errorf("could not parse %s: %w", pathToLockfile, err)
}

packages := make([]PackageDetails, 0, len(parsedLockfile.Packages))

for _, lockPackage := range parsedLockfile.Packages {
// skip including the root "package", since its name and version are most likely arbitrary
if lockPackage.Source.Virtual == "." {
continue
}

_, commit, _ := strings.Cut(lockPackage.Source.Git, "#")

packages = append(packages, PackageDetails{
Name: lockPackage.Name,
Version: lockPackage.Version,
Ecosystem: UvEcosystem,
CompareAs: UvEcosystem,
Commit: commit,
})
}

return packages, nil
}
198 changes: 198 additions & 0 deletions pkg/lockfile/parse-uv-lock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package lockfile_test

import (
"testing"

"github.com/g-rath/osv-detector/pkg/lockfile"
)

func TestParseUvLock_FileDoesNotExist(t *testing.T) {
t.Parallel()

packages, err := lockfile.ParseUvLock("fixtures/uv/does-not-exist")

expectErrContaining(t, err, "could not read")
expectPackages(t, packages, []lockfile.PackageDetails{})
}

func TestParseUvLock_InvalidToml(t *testing.T) {
t.Parallel()

packages, err := lockfile.ParseUvLock("fixtures/uv/not-toml.txt")

expectErrContaining(t, err, "could not parse")
expectPackages(t, packages, []lockfile.PackageDetails{})
}

func TestParseUvLock_NoPackages(t *testing.T) {
t.Parallel()

packages, err := lockfile.ParseUvLock("fixtures/uv/empty.lock")

if err != nil {
t.Errorf("Got unexpected error: %v", err)
}

expectPackages(t, packages, []lockfile.PackageDetails{})
}

func TestParseUvLock_OnePackage(t *testing.T) {
t.Parallel()

packages, err := lockfile.ParseUvLock("fixtures/uv/one-package.lock")

if err != nil {
t.Errorf("Got unexpected error: %v", err)
}

expectPackages(t, packages, []lockfile.PackageDetails{
{
Name: "emoji",
Version: "2.14.0",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
})
}

func TestParseUvLock_TwoPackages(t *testing.T) {
t.Parallel()

packages, err := lockfile.ParseUvLock("fixtures/uv/two-packages.lock")

if err != nil {
t.Errorf("Got unexpected error: %v", err)
}

expectPackages(t, packages, []lockfile.PackageDetails{
{
Name: "emoji",
Version: "2.14.0",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "protobuf",
Version: "4.25.5",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
})
}

func TestParseUvLock_SourceGit(t *testing.T) {
t.Parallel()

packages, err := lockfile.ParseUvLock("fixtures/uv/source-git.lock")

if err != nil {
t.Errorf("Got unexpected error: %v", err)
}

expectPackages(t, packages, []lockfile.PackageDetails{
{
Name: "ruff",
Version: "0.8.1",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
Commit: "84748be16341b76e073d117329f7f5f4ee2941ad",
},
})
}

func TestParseUvLock_GroupedPackages(t *testing.T) {
t.Parallel()

packages, err := lockfile.ParseUvLock("fixtures/uv/grouped-packages.lock")

if err != nil {
t.Errorf("Got unexpected error: %v", err)
}

expectPackages(t, packages, []lockfile.PackageDetails{
{
Name: "emoji",
Version: "2.14.0",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "click",
Version: "8.1.7",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "colorama",
Version: "0.4.6",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "black",
Version: "24.10.0",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "flake8",
Version: "7.1.1",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "mccabe",
Version: "0.7.0",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "mypy-extensions",
Version: "1.0.0",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "packaging",
Version: "24.2",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "pathspec",
Version: "0.12.1",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "platformdirs",
Version: "4.3.6",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "pycodestyle",
Version: "2.12.1",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "pyflakes",
Version: "3.2.0",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "tomli",
Version: "2.2.1",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
{
Name: "typing-extensions",
Version: "4.12.2",
Ecosystem: lockfile.UvEcosystem,
CompareAs: lockfile.UvEcosystem,
},
})
}
Loading

0 comments on commit afe5e1d

Please sign in to comment.