Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ Style/HashSlice:
Style/SafeNavigationChainLength:
Enabled: false

# Offense count: 674
# Offense count: 662
Sorbet/ForbidTUntyped:
Exclude:
- "bun/lib/dependabot/bun.rb"
Expand Down
48 changes: 45 additions & 3 deletions python/lib/dependabot/python/file_parser/pyproject_document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,25 +98,54 @@ class PoetrySource < T::ImmutableStruct
const :url, T.nilable(String), default: nil
end

class ProjectMetadata < T::ImmutableStruct
const :name, T.nilable(String), default: nil
const :description, T.nilable(String), default: nil
end

sig { params(data: PyprojectValueParser::ObjectHash).void }
def initialize(data)
@data = data
end

sig { params(file: Dependabot::DependencyFile).returns(PyprojectDocument) }
def self.from_file(file)
content = T.must(file.content)
parsed = T.cast(TomlRB.parse(content), Object)
new(PyprojectValueParser.object_hash(parsed, "pyproject.toml"))
from_content(T.must(file.content))
rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
raise Dependabot::DependencyFileNotParseable, file.path
end

sig { params(content: String).returns(PyprojectDocument) }
def self.from_content(content)
parsed = T.cast(TomlRB.parse(content), Object)
new(PyprojectValueParser.object_hash(parsed, "pyproject.toml"))
end

sig { returns(T::Boolean) }
def poetry?
!poetry_root.nil?
end

sig { returns(T::Boolean) }
def project?
!section(@data, "project", "project").nil?
end

sig { returns(T.nilable(ProjectMetadata)) }
def poetry_metadata
metadata_from(poetry_root, "tool.poetry")
end

sig { returns(T.nilable(ProjectMetadata)) }
def project_metadata
metadata_from(section(@data, "project", "project"), "project")
end

sig { returns(T.nilable(ProjectMetadata)) }
def build_system_metadata
metadata_from(section(@data, "build-system", "build-system"), "build-system")
end

sig { returns(T::Boolean) }
def pep621?
project = section(@data, "project", "project")
Expand Down Expand Up @@ -200,6 +229,19 @@ def workspace_globs(key)

private

sig do
params(data: T.nilable(PyprojectValueParser::ObjectHash), context: String)
.returns(T.nilable(ProjectMetadata))
end
def metadata_from(data, context)
return unless data

ProjectMetadata.new(
name: PyprojectValueParser.optional_string(data["name"], "#{context}.name"),
description: PyprojectValueParser.optional_string(data["description"], "#{context}.description")
)
end

sig { returns(T.nilable(PyprojectValueParser::ObjectHash)) }
def poetry_root
tool = section(@data, "tool", "tool")
Expand Down
94 changes: 47 additions & 47 deletions python/lib/dependabot/python/update_checker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
# frozen_string_literal: true

require "excon"
require "toml-rb"
require "json"
require "sorbet-runtime"

require "dependabot/dependency"
require "dependabot/dependency_requirement"
require "dependabot/errors"
require "dependabot/python/file_parser"
require "dependabot/python/name_normaliser"
require "dependabot/python/requirement_parser"
require "dependabot/python/requirement"
Expand All @@ -29,6 +30,8 @@ class UpdateChecker < Dependabot::UpdateCheckers::Base
require_relative "update_checker/requirements_updater"
require_relative "update_checker/latest_version_finder"

PyprojectDocument = FileParser::PyprojectDocument

MAIN_PYPI_INDEXES = %w(
https://pypi.python.org/simple/
https://pypi.org/simple/
Expand Down Expand Up @@ -248,7 +251,7 @@ def pyproject_resolver
# For hybrid projects with both [tool.poetry] and [project] sections but no lockfile,
# use the requirements resolver to handle PEP 621 dependencies
# For pure Poetry projects, use Poetry resolver even without lockfile
return :poetry if poetry_based? && (poetry_lock || !standard_details)
return :poetry if poetry_based? && (poetry_lock || !pyproject_document.project?)

:requirements
end
Expand Down Expand Up @@ -395,7 +398,7 @@ def latest_version_finder

sig { returns(T::Boolean) }
def poetry_based?
updating_pyproject? && !poetry_details.nil?
updating_pyproject? && pyproject_document.poetry?
end

sig { returns(T::Boolean) }
Expand All @@ -410,22 +413,41 @@ def library?
def check_pypi_for_library_match
return false unless updating_pyproject?

library_details_temp = library_details
return false unless library_details_temp && !library_details_temp["name"].nil?
metadata = library_details
name = metadata&.name
return false unless name

has_library_metadata = !library_details_temp["description"].nil?
local_description = metadata.description
has_library_metadata = !local_description.nil?

response = Dependabot::RegistryClient.get(
url: "https://pypi.org/pypi/#{normalised_name(library_details_temp['name'])}/json/"
)
return has_library_metadata unless response.status == 200
begin
response = Dependabot::RegistryClient.get(
url: "https://pypi.org/pypi/#{normalised_name(name)}/json/"
)
return has_library_metadata unless response.status == 200

local_description = library_details_temp["description"]
return true if local_description.nil?
return true if local_description.nil?

(JSON.parse(response.body)["info"] || {})["summary"] == local_description
rescue Excon::Error::Timeout, Excon::Error::Socket, URI::InvalidURIError
has_library_metadata
pypi_summary(response.body) == local_description
rescue Excon::Error::Timeout, Excon::Error::Socket, URI::InvalidURIError
has_library_metadata
end
end

sig { params(body: String).returns(T.nilable(String)) }
def pypi_summary(body)
metadata = T.cast(JSON.parse(body), Object)
raise TypeError, "PyPI metadata must be an object" unless metadata.is_a?(Hash)

info = T.cast(metadata["info"], Object)
return if info.nil?
raise TypeError, "PyPI info must be an object" unless info.is_a?(Hash)

summary = T.cast(info["summary"], Object)
return if summary.nil?
return summary if summary.is_a?(String)

raise TypeError, "PyPI info.summary must be a string"
end

sig { returns(T::Boolean) }
Expand Down Expand Up @@ -483,43 +505,21 @@ def poetry_lock
dependency_files.find { |f| f.name == "poetry.lock" }
end

sig { returns(T.nilable(T::Hash[String, T.untyped])) }
sig { returns(T.nilable(PyprojectDocument::ProjectMetadata)) }
def library_details
@library_details ||= T.let(
poetry_details || standard_details || build_system_details,
T.nilable(T::Hash[String, T.untyped])
)
end

sig { returns(T.nilable(T::Hash[String, T.untyped])) }
def poetry_details
@poetry_details ||= T.let(
toml_content.dig("tool", "poetry"),
T.nilable(T::Hash[String, T.untyped])
)
end

sig { returns(T.nilable(T::Hash[String, T.untyped])) }
def standard_details
@standard_details ||= T.let(
toml_content["project"],
T.nilable(T::Hash[String, T.untyped])
)
end

sig { returns(T.nilable(T::Hash[String, T.untyped])) }
def build_system_details
@build_system_details ||= T.let(
toml_content["build-system"],
T.nilable(T::Hash[String, T.untyped])
pyproject_document.poetry_metadata ||
pyproject_document.project_metadata ||
pyproject_document.build_system_metadata,
T.nilable(PyprojectDocument::ProjectMetadata)
)
end

sig { returns(T::Hash[String, T.untyped]) }
def toml_content
@toml_content ||= T.let(
TomlRB.parse(T.must(pyproject).content),
T.nilable(T::Hash[String, T.untyped])
sig { returns(PyprojectDocument) }
def pyproject_document
@pyproject_document ||= T.let(
PyprojectDocument.from_content(T.must(T.must(pyproject).content)),
T.nilable(PyprojectDocument)
)
end

Expand Down
112 changes: 112 additions & 0 deletions python/spec/dependabot/python/file_parser/pyproject_document_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,116 @@
.to raise_error(TypeError, "tool.uv.workspace.members must contain only strings")
end
end

describe "library metadata" do
let(:content) do
<<~TOML
[tool.poetry]
name = "poetry-project"
description = "Poetry description"

[project]
name = "standard-project"
description = "Project description"

[build-system]
name = "build-project"
description = "Build description"
TOML
end

it "returns typed metadata for each section" do
expect(document.poetry_metadata).to have_attributes(
name: "poetry-project", description: "Poetry description"
)
expect(document.project_metadata).to have_attributes(
name: "standard-project", description: "Project description"
)
expect(document.build_system_metadata).to have_attributes(
name: "build-project", description: "Build description"
)
end

context "with an empty project section" do
let(:content) { "[project]" }

it "distinguishes a present section from a missing one" do
expect(document).to be_project
expect(document).not_to be_pep621
expect(document.project_metadata).to have_attributes(name: nil, description: nil)
expect(document.poetry_metadata).to be_nil
expect(document.build_system_metadata).to be_nil
end
end

context "without metadata sections" do
let(:content) { "" }

it "returns no metadata" do
expect(document).not_to be_project
expect(document.poetry_metadata).to be_nil
expect(document.project_metadata).to be_nil
expect(document.build_system_metadata).to be_nil
end
end

context "with a non-string name" do
let(:content) { "[project]\nname = 123" }

it "raises a contextual error when the metadata is read" do
expect(document).to be_project
expect { document.project_metadata }.to raise_error(TypeError, "project.name must be a string")
end
end

context "with a non-table metadata section" do
let(:content) { "project = 123" }

it "rejects the malformed section" do
expect { document.project_metadata }.to raise_error(TypeError, "project must be an object")
end
end

context "with a non-string description" do
let(:content) do
<<~TOML
[tool.poetry]
name = "valid-project"

[project]
description = false
TOML
end

it "validates only the requested metadata section" do
expect(document.poetry_metadata).to have_attributes(name: "valid-project", description: nil)
expect { document.project_metadata }
.to raise_error(TypeError, "project.description must be a string")
end
end

context "with unknown metadata fields" do
let(:content) { "[project]\nname = \"example\"\nextra = { nested = [1, false] }" }

it "reads known fields without validating unrelated fields" do
expect(document.project_metadata).to have_attributes(name: "example", description: nil)
end
end
end

describe ".from_content" do
it "parses the same document without a dependency file" do
expect(described_class.from_content(content).poetry_dependencies("dependencies"))
.to eq(document.poetry_dependencies("dependencies"))
end

it "preserves native TOML syntax errors" do
expect { described_class.from_content("[project\n") }.to raise_error(TomlRB::ParseError)
end

it "preserves native duplicate-key errors" do
expect { described_class.from_content("[project]\nname = \"one\"\nname = \"two\"") }
.to raise_error(TomlRB::ValueOverwriteError)
end
end
end
Loading
Loading