Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 9 additions & 25 deletions uv/lib/dependabot/uv/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class FileParser < Dependabot::FileParsers::Base

require_relative "file_parser/pyproject_files_parser"
require_relative "file_parser/python_requirement_parser"
require_relative "file_parser/lock_file_dependency_parser"

DEPENDENCY_GROUP_KEYS = T.let(
[
Expand Down Expand Up @@ -57,7 +58,7 @@ def parse
dependency_set += uv_lock_file_dependencies
dependency_set += requirement_dependencies if requirement_files.any?

dependency_set.dependencies
lock_file_dependency_parser.override_with_lockfile_versions(dependency_set.dependencies)
end

sig { override.returns(Ecosystem) }
Expand Down Expand Up @@ -188,34 +189,17 @@ def requirement_files
dependency_files.select { |f| f.name.end_with?(".txt", ".in") }
end

sig { returns(T::Array[DependencyFile]) }
def uv_lock_files
dependency_files.select { |f| f.name == "uv.lock" }
sig { returns(LockFileDependencyParser) }
def lock_file_dependency_parser
@lock_file_dependency_parser ||= T.let(
LockFileDependencyParser.new(dependency_files: dependency_files),
T.nilable(LockFileDependencyParser)
)
end

sig { returns(DependencySet) }
def uv_lock_file_dependencies
dependency_set = DependencySet.new

uv_lock_files.each do |file|
lockfile_content = TomlRB.parse(file.content)
packages = lockfile_content.fetch("package", [])

packages.each do |package_data|
next unless package_data.is_a?(Hash) && package_data["name"] && package_data["version"]

dependency_set << Dependency.new(
name: normalised_name(package_data["name"]),
version: package_data["version"],
requirements: [], # Lock files don't contain requirements
package_manager: "uv"
)
end
rescue StandardError => e
Dependabot.logger.warn("Error parsing uv.lock: #{e.message}")
end

dependency_set
lock_file_dependency_parser.dependency_set
end

sig { returns(DependencySet) }
Expand Down
115 changes: 115 additions & 0 deletions uv/lib/dependabot/uv/file_parser/lock_file_dependency_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# typed: strict
# frozen_string_literal: true

require "toml-rb"

require "dependabot/dependency"
require "dependabot/file_parsers/base/dependency_set"
require "dependabot/uv/file_parser"
require "dependabot/uv/name_normaliser"

module Dependabot
module Uv
class FileParser
# Parses dependencies out of uv.lock files and provides a helper for
# preferring lockfile-resolved versions over any (potentially stale)
# versions discovered in requirements.txt/.in or pyproject.toml.
class LockFileDependencyParser
extend T::Sig

sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).void }
def initialize(dependency_files:)
@dependency_files = dependency_files
end

sig { returns(Dependabot::FileParsers::Base::DependencySet) }
def dependency_set
@dependency_set ||= T.let(
build_dependency_set,
T.nilable(Dependabot::FileParsers::Base::DependencySet)
)
end

# `DependencySet#combined_version` can pick a stale `requirements.txt`
# version over `uv.lock` when both list the same package — `uv.lock`
# entries have empty requirements (so `top_level?` is false) and lose
# the merge. Override the merged version with the lockfile version for
# any package present in `uv.lock`, keeping the merged requirements so
# the file updater can still operate on the requirements files.
sig do
params(dependencies: T::Array[Dependabot::Dependency])
.returns(T::Array[Dependabot::Dependency])
end
def override_with_lockfile_versions(dependencies)
return dependencies if lockfile_versions.empty?

dependencies.map { |dep| override_version(dep, lockfile_versions[dep.name]) }
end

private

sig { returns(T::Array[Dependabot::DependencyFile]) }
attr_reader :dependency_files

sig do
params(dep: Dependabot::Dependency, lock_version: T.nilable(String))
.returns(Dependabot::Dependency)
end
def override_version(dep, lock_version)
return dep if lock_version.nil? # not in uv.lock
return dep if dep.version == lock_version # merged version already correct

Dependabot::Dependency.new(
name: dep.name,
version: lock_version,
requirements: dep.requirements,
package_manager: dep.package_manager,
subdependency_metadata: dep.subdependency_metadata,
metadata: dep.metadata
)
end

sig { returns(T::Array[Dependabot::DependencyFile]) }
def uv_lock_files
dependency_files.select { |f| f.name == "uv.lock" }
end

sig { returns(Dependabot::FileParsers::Base::DependencySet) }
def build_dependency_set
set = Dependabot::FileParsers::Base::DependencySet.new

uv_lock_files.each do |file|
lockfile_content = TomlRB.parse(file.content)
packages = lockfile_content.fetch("package", [])

packages.each do |package_data|
next unless package_data.is_a?(Hash) && package_data["name"] && package_data["version"]

set << Dependabot::Dependency.new(
name: NameNormaliser.normalise(package_data["name"]),
version: package_data["version"],
requirements: [], # Lock files don't contain requirements
package_manager: "uv"
)
end
rescue StandardError => e
Dependabot.logger.warn("Error parsing uv.lock: #{e.message}")
end

set
end

sig { returns(T::Hash[String, String]) }
def lockfile_versions
@lockfile_versions ||= T.let(
dependency_set.dependencies.each_with_object({}) do |dep, hash|
version = dep.version
hash[dep.name] = version if version
end,
T.nilable(T::Hash[String, String])
)
end
end
end
end
end
39 changes: 39 additions & 0 deletions uv/spec/dependabot/uv/file_parser_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,45 @@
end
end
end

context "when a requirements.txt with stale versions is also present" do
let(:files) { [pyproject, uv_lock, stale_requirements] }
let(:stale_requirements) do
Dependabot::DependencyFile.new(
name: "third-party/requirements.txt",
content: "requests==2.28.0\n"
)
end

it "uses the version from uv.lock, not requirements.txt" do
requests_dep = dependencies.find { |d| d.name == "requests" }
expect(requests_dep.version).to eq("2.32.3")
end

it "preserves the requirements.txt entry so the file updater can still update it" do
requests_dep = dependencies.find { |d| d.name == "requests" }
req_files = requests_dep.requirements.map { |r| r[:file] }
expect(req_files).to include("third-party/requirements.txt")
end
end

context "when a requirements.txt contains a package that is not in uv.lock" do
let(:files) { [pyproject, uv_lock, extra_requirements] }
let(:extra_requirements) do
Dependabot::DependencyFile.new(
name: "third-party/requirements.txt",
content: "some-tool==1.0.0\n"
)
end

it "parses the dependency from requirements.txt" do
extra_dep = dependencies.find { |d| d.name == "some-tool" }
expect(extra_dep).not_to be_nil
expect(extra_dep.version).to eq("1.0.0")
expect(extra_dep.requirements.map { |r| r[:file] })
.to include("third-party/requirements.txt")
end
end
end

context "with uv workspace member pyprojects" do
Expand Down
Loading