Skip to content

Commit 9776317

Browse files
authored
Merge branch 'main' into chp/cooldown-filter-fix
2 parents 01c0669 + e70a010 commit 9776317

8 files changed

Lines changed: 424 additions & 11 deletions

File tree

python/lib/dependabot/python/file_updater/poetry_file_updater.rb

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@ def replace_dep(dep, content, new_r, old_r)
124124
declaration_match = content.match(declaration_regex)
125125
if declaration_match
126126
declaration = declaration_match[:declaration]
127-
new_declaration = T.must(declaration).sub(old_req, new_req)
128-
return content.sub(T.must(declaration), new_declaration)
127+
if T.must(declaration).include?(old_req)
128+
new_declaration = T.must(declaration).sub(old_req, new_req)
129+
return content.sub(T.must(declaration), new_declaration)
130+
end
129131
end
130132

131133
# Try Poetry table format
@@ -220,9 +222,8 @@ def prepared_pyproject
220222

221223
sig { params(pyproject_content: String).returns(String) }
222224
def freeze_other_dependencies(pyproject_content)
223-
PyprojectPreparer
224-
.new(pyproject_content: pyproject_content, lockfile: lockfile)
225-
.freeze_top_level_dependencies_except(dependencies)
225+
PyprojectPreparer.new(pyproject_content: pyproject_content, lockfile: lockfile)
226+
.freeze_top_level_dependencies_except(dependencies)
226227
end
227228

228229
sig { params(pyproject_content: String).returns(String) }
@@ -244,14 +245,18 @@ def freeze_dependencies_being_updated(pyproject_content)
244245
end
245246
end
246247

248+
# Freeze PEP 621 project.dependencies and project.optional-dependencies
249+
PyprojectPreparer.freeze_pep621_deps!(pyproject_object, dependencies) do |dep|
250+
!git_dependency_being_updated?(dep)
251+
end
252+
247253
TomlRB.dump(pyproject_object)
248254
end
249255

250256
sig { params(pyproject_content: String).returns(String) }
251257
def update_python_requirement(pyproject_content)
252-
PyprojectPreparer
253-
.new(pyproject_content: pyproject_content)
254-
.update_python_requirement(language_version_manager.python_version)
258+
PyprojectPreparer.new(pyproject_content: pyproject_content)
259+
.update_python_requirement(language_version_manager.python_version)
255260
end
256261

257262
sig { params(poetry_object: T::Hash[String, T.untyped], dep: Dependabot::Dependency).returns(T::Array[String]) }
@@ -262,6 +267,8 @@ def lock_declaration_to_new_version!(poetry_object, dep)
262267
next unless pkg_name
263268

264269
if poetry_object[type][pkg_name].is_a?(Hash)
270+
next unless poetry_object[type][pkg_name].key?("version") # skip enrichment-only entries
271+
265272
poetry_object[type][pkg_name]["version"] = dep.version
266273
else
267274
poetry_object[type][pkg_name] = dep.version
@@ -284,9 +291,7 @@ def git_dependency_being_updated?(dep)
284291

285292
sig { params(pyproject_content: String).returns(String) }
286293
def sanitize(pyproject_content)
287-
PyprojectPreparer
288-
.new(pyproject_content: pyproject_content)
289-
.sanitize
294+
PyprojectPreparer.new(pyproject_content: pyproject_content).sanitize
290295
end
291296

292297
sig { params(pyproject_content: String).returns(String) }

python/lib/dependabot/python/file_updater/pyproject_preparer.rb

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,80 @@ class FileUpdater
1616
class PyprojectPreparer
1717
extend T::Sig
1818

19+
# Builds a regex pattern that matches a PEP 508 package name,
20+
# treating hyphens, underscores, and dots as interchangeable per PEP 508.
21+
sig { params(name: String).returns(String) }
22+
def self.pep508_name_pattern(name)
23+
Regexp.escape(name).gsub("\\-", "[-_.]").gsub("_", "[-_.]").gsub("\\.", "[-_.]")
24+
end
25+
private_class_method :pep508_name_pattern
26+
27+
# Pins a single PEP 508 dependency entry string to a specific version,
28+
# preserving extras and environment markers.
29+
sig { params(entry: String, name_pattern: String, version: String).returns(String) }
30+
def self.pin_pep508_entry(entry, name_pattern, version)
31+
if entry.match?(/\A#{name_pattern}(\[.*?\])?\s*[><=!~]/i)
32+
entry.sub(
33+
/(?<pre>#{name_pattern}(?:\[.*?\])?)\s*[><=!~][^;]*?(?=\s*;|\s*\z)/i,
34+
"\\k<pre>==#{version}"
35+
)
36+
else
37+
entry.sub(
38+
/(?<pre>#{name_pattern}(?:\[.*?\])?)(?<rest>\s*(?:;.*)?)/i,
39+
"\\k<pre>==#{version}\\k<rest>"
40+
)
41+
end
42+
end
43+
private_class_method :pin_pep508_entry
44+
45+
# Freezes PEP 621 dependencies in-place within a parsed pyproject object.
46+
# Replaces version specifiers with ==dep.version for each matching dep.
47+
# Accepts an optional block to filter which dependencies to freeze.
48+
sig do
49+
params(
50+
pyproject_object: T::Hash[String, T.untyped],
51+
deps: T::Array[Dependabot::Dependency],
52+
blk: T.nilable(T.proc.params(dep: Dependabot::Dependency).returns(T::Boolean))
53+
).void
54+
end
55+
def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
56+
dep_arrays = collect_pep621_dep_arrays(pyproject_object)
57+
return if dep_arrays.empty?
58+
59+
deps.each do |dep|
60+
next if blk && !yield(dep)
61+
next unless dep.version
62+
63+
pin_pep621_dep_in_arrays!(dep_arrays, dep)
64+
end
65+
end
66+
67+
sig { params(pyproject_object: T::Hash[String, T.untyped]).returns(T::Array[T::Array[String]]) }
68+
def self.collect_pep621_dep_arrays(pyproject_object)
69+
project_object = pyproject_object["project"]
70+
return [] unless project_object
71+
72+
dep_arrays = [project_object["dependencies"]]
73+
project_object["optional-dependencies"]&.each_value { |opt| dep_arrays << opt }
74+
dep_arrays.compact!
75+
dep_arrays.select! { |arr| arr.is_a?(Array) && arr.all?(String) }
76+
dep_arrays
77+
end
78+
private_class_method :collect_pep621_dep_arrays
79+
80+
sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
81+
def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
82+
name_pattern = pep508_name_pattern(dep.name)
83+
dep_arrays.each do |arr|
84+
arr.each_with_index do |entry, i|
85+
next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*(\z|[><=!~;,])/i)
86+
87+
arr[i] = pin_pep508_entry(entry, name_pattern, T.must(dep.version))
88+
end
89+
end
90+
end
91+
private_class_method :pin_pep621_dep_in_arrays!
92+
1993
sig { params(pyproject_content: String, lockfile: T.nilable(Dependabot::DependencyFile)).void }
2094
def initialize(pyproject_content:, lockfile: nil)
2195
@pyproject_content = pyproject_content
@@ -109,6 +183,9 @@ def freeze_top_level_dependencies_except(dependencies)
109183
end
110184
end
111185

186+
# Freeze PEP 621 project.dependencies and project.optional-dependencies
187+
freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
188+
112189
TomlRB.dump(pyproject_object)
113190
end
114191
# rubocop:enable Metrics/AbcSize
@@ -122,6 +199,38 @@ def freeze_top_level_dependencies_except(dependencies)
122199
sig { returns(T.nilable(Dependabot::DependencyFile)) }
123200
attr_reader :lockfile
124201

202+
sig { params(pyproject_object: T::Hash[String, T.untyped], excluded_names: T::Array[String]).void }
203+
def freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
204+
project_object = pyproject_object["project"]
205+
return unless project_object
206+
207+
freeze_pep621_dep_array!(project_object["dependencies"], excluded_names)
208+
209+
project_object["optional-dependencies"]&.each_value do |opt_deps|
210+
freeze_pep621_dep_array!(opt_deps, excluded_names)
211+
end
212+
end
213+
214+
sig { params(dep_array: T.nilable(T::Array[String]), excluded_names: T::Array[String]).void }
215+
def freeze_pep621_dep_array!(dep_array, excluded_names)
216+
return unless dep_array
217+
218+
dep_array.each_with_index do |entry, index|
219+
# Extract dependency name from PEP 508 string
220+
match = entry.match(/\A([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i)
221+
next unless match
222+
223+
dep_name = normalise(T.must(match[1]))
224+
next if excluded_names.include?(dep_name)
225+
226+
locked_details = locked_details(dep_name)
227+
next unless (locked_version = locked_details&.fetch("version"))
228+
229+
name_pattern = self.class.send(:pep508_name_pattern, T.must(match[1]))
230+
dep_array[index] = self.class.send(:pin_pep508_entry, entry, name_pattern, locked_version)
231+
end
232+
end
233+
125234
sig { params(dep_name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
126235
def locked_details(dep_name)
127236
parsed_lockfile.fetch("package")

python/spec/dependabot/python/file_updater/poetry_file_updater_spec.rb

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,115 @@
848848
end
849849
end
850850

851+
describe "hybrid Poetry v2 projects" do
852+
let(:dependency_files) { [pyproject] }
853+
854+
context "when dependency has version in both project.dependencies and tool.poetry.dependencies" do
855+
let(:pyproject_fixture_name) { "pep621_hybrid_version_in_both.toml" }
856+
let(:dependency) do
857+
Dependabot::Dependency.new(
858+
name: "requests",
859+
version: "2.19.1",
860+
previous_version: "2.13.0",
861+
package_manager: "pip",
862+
requirements: [
863+
{
864+
requirement: ">=2.19.1",
865+
file: "pyproject.toml",
866+
source: nil,
867+
groups: ["dependencies"]
868+
},
869+
{
870+
requirement: "^2.19",
871+
file: "pyproject.toml",
872+
source: nil,
873+
groups: ["dependencies"]
874+
}
875+
],
876+
previous_requirements: [
877+
{
878+
requirement: ">=2.13.0",
879+
file: "pyproject.toml",
880+
source: nil,
881+
groups: ["dependencies"]
882+
},
883+
{
884+
requirement: "^2.13",
885+
file: "pyproject.toml",
886+
source: nil,
887+
groups: ["dependencies"]
888+
}
889+
]
890+
)
891+
end
892+
893+
describe "#updated_dependency_files" do
894+
subject(:updated_files) { updater.updated_dependency_files }
895+
896+
it "updates the version in project.dependencies" do
897+
updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
898+
expect(updated_pyproject.content).to include('"requests>=2.19.1"')
899+
expect(updated_pyproject.content).not_to include('"requests>=2.13.0"')
900+
end
901+
902+
it "updates the version in tool.poetry.dependencies" do
903+
updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
904+
parsed = TomlRB.parse(updated_pyproject.content)
905+
poetry_req = parsed.dig("tool", "poetry", "dependencies", "requests")
906+
expect(poetry_req["version"]).to eq("^2.19")
907+
end
908+
909+
it "preserves enrichment metadata in tool.poetry.dependencies" do
910+
updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
911+
parsed = TomlRB.parse(updated_pyproject.content)
912+
poetry_req = parsed.dig("tool", "poetry", "dependencies", "requests")
913+
expect(poetry_req["source"]).to eq("private-source")
914+
end
915+
end
916+
end
917+
918+
context "when tool.poetry.dependencies has enrichment only (no version key)" do
919+
let(:pyproject_fixture_name) { "pep621_hybrid_enrichment_only.toml" }
920+
let(:dependency) do
921+
Dependabot::Dependency.new(
922+
name: "requests",
923+
version: "2.19.1",
924+
previous_version: "2.13.0",
925+
package_manager: "pip",
926+
requirements: [{
927+
requirement: ">=2.19.1",
928+
file: "pyproject.toml",
929+
source: nil,
930+
groups: ["dependencies"]
931+
}],
932+
previous_requirements: [{
933+
requirement: ">=2.13.0",
934+
file: "pyproject.toml",
935+
source: nil,
936+
groups: ["dependencies"]
937+
}]
938+
)
939+
end
940+
941+
describe "#updated_dependency_files" do
942+
subject(:updated_files) { updater.updated_dependency_files }
943+
944+
it "updates the version in project.dependencies" do
945+
updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
946+
expect(updated_pyproject.content).to include('"requests>=2.19.1"')
947+
expect(updated_pyproject.content).not_to include('"requests>=2.13.0"')
948+
end
949+
950+
it "leaves the enrichment-only entry in tool.poetry.dependencies unchanged" do
951+
updated_pyproject = updated_files.find { |f| f.name == "pyproject.toml" }
952+
parsed = TomlRB.parse(updated_pyproject.content)
953+
poetry_req = parsed.dig("tool", "poetry", "dependencies", "requests")
954+
expect(poetry_req).to eq({ "source" => "private-source" })
955+
end
956+
end
957+
end
958+
end
959+
851960
describe "#prepared_project_file" do
852961
subject(:prepared_project) { updater.send(:prepared_pyproject) }
853962

0 commit comments

Comments
 (0)