dependabot-python 0.368.0 → 0.370.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/helpers/build +4 -0
  3. data/helpers/lib/parser.py +26 -0
  4. data/helpers/requirements.txt +1 -0
  5. data/helpers/test/fixtures/no_dependencies.toml +3 -0
  6. data/helpers/test/fixtures/pep621_arbitrary_equality.toml +7 -0
  7. data/helpers/test/fixtures/pep621_dependencies.toml +21 -0
  8. data/helpers/test/fixtures/pep621_empty_deps.toml +8 -0
  9. data/helpers/test/fixtures/pep621_extras.toml +8 -0
  10. data/helpers/test/fixtures/pep621_markers.toml +7 -0
  11. data/helpers/test/fixtures/pep621_multiple_extras.toml +7 -0
  12. data/helpers/test/fixtures/pep621_no_version.toml +8 -0
  13. data/helpers/test/fixtures/pep621_only_build_system.toml +3 -0
  14. data/helpers/test/fixtures/pep621_spaced_specifiers.toml +10 -0
  15. data/helpers/test/fixtures/pep735_cycle.toml +13 -0
  16. data/helpers/test/fixtures/pep735_dependency_groups.toml +18 -0
  17. data/helpers/test/fixtures/requirements/constraints.txt +1 -0
  18. data/helpers/test/fixtures/requirements/markers.txt +1 -0
  19. data/helpers/test/fixtures/requirements/requirements-dev.txt +2 -0
  20. data/helpers/test/fixtures/requirements/requirements.txt +5 -0
  21. data/helpers/test/fixtures/requirements/with_constraints.txt +2 -0
  22. data/helpers/test/fixtures/requirements_empty/.gitkeep +0 -0
  23. data/helpers/test/fixtures/setup_cfg/setup.cfg +16 -0
  24. data/helpers/test/fixtures/setup_py/setup.py +20 -0
  25. data/helpers/test/fixtures/setup_py_comments/setup.py +9 -0
  26. data/helpers/test/test_hasher.py +114 -0
  27. data/helpers/test/test_parse_requirements.py +103 -0
  28. data/helpers/test/test_parse_setup.py +127 -0
  29. data/helpers/test/test_parser.py +265 -0
  30. data/helpers/test/test_run.py +49 -0
  31. data/lib/dependabot/python/dependency_grapher/lockfile_generator.rb +13 -0
  32. data/lib/dependabot/python/file_parser/pyproject_files_parser.rb +42 -11
  33. data/lib/dependabot/python/file_parser.rb +21 -1
  34. data/lib/dependabot/python/file_updater/poetry_file_updater/pep621_updater.rb +162 -0
  35. data/lib/dependabot/python/file_updater/poetry_file_updater.rb +60 -77
  36. data/lib/dependabot/python/file_updater/pyproject_preparer.rb +139 -27
  37. data/lib/dependabot/python/package_manager.rb +16 -0
  38. data/lib/dependabot/python/poetry_plugin_installer.rb +95 -0
  39. data/lib/dependabot/python/update_checker/latest_version_finder.rb +4 -2
  40. data/lib/dependabot/python/update_checker/poetry_version_resolver.rb +13 -0
  41. data/lib/dependabot/python/update_checker/requirements_updater.rb +86 -15
  42. data/lib/dependabot/python/update_checker.rb +6 -2
  43. metadata +32 -4
@@ -13,12 +13,14 @@ require "dependabot/python/file_parser/python_requirement_parser"
13
13
  require "dependabot/python/file_updater"
14
14
  require "dependabot/python/native_helpers"
15
15
  require "dependabot/python/name_normaliser"
16
+ require "dependabot/python/poetry_plugin_installer"
16
17
 
17
18
  module Dependabot
18
19
  module Python
19
20
  class FileUpdater
20
21
  class PoetryFileUpdater
21
22
  require_relative "pyproject_preparer"
23
+ require_relative "poetry_file_updater/pep621_updater"
22
24
  extend T::Sig
23
25
 
24
26
  sig { returns(T::Array[Dependabot::DependencyFile]) }
@@ -49,6 +51,7 @@ module Dependabot
49
51
  @language_version_manager = T.let(nil, T.nilable(LanguageVersionManager))
50
52
  @python_requirement_parser = T.let(nil, T.nilable(FileParser::PythonRequirementParser))
51
53
  @updated_pyproject_content = T.let(nil, T.nilable(String))
54
+ @poetry_plugin_installer = T.let(nil, T.nilable(PoetryPluginInstaller))
52
55
  @poetry_lock = T.let(nil, T.nilable(Dependabot::DependencyFile))
53
56
  end
54
57
 
@@ -77,9 +80,7 @@ module Dependabot
77
80
  )
78
81
  end
79
82
 
80
- raise "Expected lockfile to change!" if lockfile && lockfile&.content == updated_lockfile_content
81
-
82
- if lockfile
83
+ if lockfile && lockfile&.content != updated_lockfile_content
83
84
  updated_files <<
84
85
  updated_file(file: T.must(lockfile), content: updated_lockfile_content)
85
86
  end
@@ -105,50 +106,51 @@ module Dependabot
105
106
  updated_content
106
107
  end
107
108
 
108
- sig do
109
- params(
110
- dep: Dependabot::Dependency,
111
- content: String,
112
- new_r: T::Hash[Symbol, T.untyped],
113
- old_r: T::Hash[Symbol, T.untyped]
114
- ).returns(String)
115
- end
109
+ sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(String) }
116
110
  def replace_dep(dep, content, new_r, old_r)
117
- # Handle Git dependencies with tags
118
111
  return update_git_tag(dep, content, new_r, old_r) if git_dependency?(new_r) && git_dependency?(old_r)
119
112
 
113
+ replace_poetry_dep(dep, content, new_r, old_r) ||
114
+ replace_poetry_table_dep(dep, content, new_r, old_r) ||
115
+ replace_pep621_dep(dep, content, new_r, old_r) ||
116
+ content
117
+ end
118
+
119
+ sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
120
+ def replace_poetry_dep(dep, content, new_r, old_r)
120
121
  new_req = new_r[:requirement]
121
122
  old_req = old_r[:requirement]
122
123
 
123
124
  declaration_regex = declaration_regex(dep, old_r)
124
125
  declaration_match = content.match(declaration_regex)
125
- if declaration_match
126
- declaration = declaration_match[:declaration]
127
- new_declaration = T.must(declaration).sub(old_req, new_req)
128
- return content.sub(T.must(declaration), new_declaration)
129
- end
126
+ return unless declaration_match
130
127
 
131
- # Try Poetry table format
132
- table_match = content.match(table_declaration_regex(dep, new_r))
133
- if table_match
134
- return content.gsub(table_declaration_regex(dep, new_r)) do |match|
135
- match.gsub(
136
- /(\s*version\s*=\s*["'])#{Regexp.escape(old_req)}/,
137
- '\1' + new_req
138
- )
139
- end
140
- end
128
+ declaration = declaration_match[:declaration]
129
+ return unless T.must(declaration).include?(old_req)
130
+
131
+ new_declaration = T.must(declaration).sub(old_req, new_req)
132
+ content.sub(T.must(declaration), new_declaration)
133
+ end
141
134
 
142
- # Try PEP 621 array format (e.g., dependencies = ["django==5.0.0"])
143
- pep621_regex = pep621_declaration_regex(dep, old_req)
144
- pep621_match = content.match(pep621_regex)
145
- if pep621_match
146
- declaration = pep621_match[:declaration]
147
- new_declaration = T.must(declaration).sub(old_req, new_req)
148
- return content.sub(T.must(declaration), new_declaration)
135
+ sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
136
+ def replace_poetry_table_dep(dep, content, new_r, old_r)
137
+ old_req = old_r[:requirement]
138
+ new_req = new_r[:requirement]
139
+ regex = table_declaration_regex(dep, new_r)
140
+
141
+ return unless content.match(regex)
142
+
143
+ content.gsub(regex) do |match|
144
+ match.gsub(
145
+ /(\s*version\s*=\s*["'])#{Regexp.escape(old_req)}/,
146
+ '\1' + new_req
147
+ )
149
148
  end
149
+ end
150
150
 
151
- content
151
+ sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
152
+ def replace_pep621_dep(dep, content, new_r, old_r)
153
+ Pep621Updater.new(dep: dep).replace(content, new_r, old_r)
152
154
  end
153
155
 
154
156
  sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
@@ -156,14 +158,7 @@ module Dependabot
156
158
  req.dig(:source, :type) == "git"
157
159
  end
158
160
 
159
- sig do
160
- params(
161
- dep: Dependabot::Dependency,
162
- content: String,
163
- new_r: T::Hash[Symbol, T.untyped],
164
- old_r: T::Hash[Symbol, T.untyped]
165
- ).returns(String)
166
- end
161
+ sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(String) }
167
162
  def update_git_tag(dep, content, new_r, old_r)
168
163
  old_tag = old_r.dig(:source, :ref)
169
164
  new_tag = new_r.dig(:source, :ref)
@@ -220,9 +215,8 @@ module Dependabot
220
215
 
221
216
  sig { params(pyproject_content: String).returns(String) }
222
217
  def freeze_other_dependencies(pyproject_content)
223
- PyprojectPreparer
224
- .new(pyproject_content: pyproject_content, lockfile: lockfile)
225
- .freeze_top_level_dependencies_except(dependencies)
218
+ PyprojectPreparer.new(pyproject_content: pyproject_content, lockfile: lockfile)
219
+ .freeze_top_level_dependencies_except(dependencies)
226
220
  end
227
221
 
228
222
  sig { params(pyproject_content: String).returns(String) }
@@ -244,14 +238,18 @@ module Dependabot
244
238
  end
245
239
  end
246
240
 
241
+ # Freeze PEP 621 project.dependencies and project.optional-dependencies
242
+ PyprojectPreparer.freeze_pep621_deps!(pyproject_object, dependencies) do |dep|
243
+ !git_dependency_being_updated?(dep)
244
+ end
245
+
247
246
  TomlRB.dump(pyproject_object)
248
247
  end
249
248
 
250
249
  sig { params(pyproject_content: String).returns(String) }
251
250
  def update_python_requirement(pyproject_content)
252
- PyprojectPreparer
253
- .new(pyproject_content: pyproject_content)
254
- .update_python_requirement(language_version_manager.python_version)
251
+ PyprojectPreparer.new(pyproject_content: pyproject_content)
252
+ .update_python_requirement(language_version_manager.python_version)
255
253
  end
256
254
 
257
255
  sig { params(poetry_object: T::Hash[String, T.untyped], dep: Dependabot::Dependency).returns(T::Array[String]) }
@@ -262,6 +260,8 @@ module Dependabot
262
260
  next unless pkg_name
263
261
 
264
262
  if poetry_object[type][pkg_name].is_a?(Hash)
263
+ next unless poetry_object[type][pkg_name].key?("version") # skip enrichment-only entries
264
+
265
265
  poetry_object[type][pkg_name]["version"] = dep.version
266
266
  else
267
267
  poetry_object[type][pkg_name] = dep.version
@@ -284,9 +284,7 @@ module Dependabot
284
284
 
285
285
  sig { params(pyproject_content: String).returns(String) }
286
286
  def sanitize(pyproject_content)
287
- PyprojectPreparer
288
- .new(pyproject_content: pyproject_content)
289
- .sanitize
287
+ PyprojectPreparer.new(pyproject_content: pyproject_content).sanitize
290
288
  end
291
289
 
292
290
  sig { params(pyproject_content: String).returns(String) }
@@ -297,6 +295,7 @@ module Dependabot
297
295
  add_auth_env_vars
298
296
 
299
297
  language_version_manager.install_required_python
298
+ poetry_plugin_installer.install_required_plugins
300
299
 
301
300
  # use system git instead of the pure Python dulwich
302
301
  run_poetry_command("pyenv exec poetry config system-git-client true")
@@ -345,17 +344,7 @@ module Dependabot
345
344
  .add_auth_env_vars(credentials)
346
345
  end
347
346
 
348
- sig do
349
- params(
350
- pyproject_content: String
351
- ).returns(T.nilable(
352
- T.any(
353
- T::Hash[String, T.untyped],
354
- String,
355
- T::Array[T::Hash[String, T.untyped]]
356
- )
357
- ))
358
- end
347
+ sig { params(pyproject_content: String).returns(T.nilable(T.any(T::Hash[String, T.untyped], String, T::Array[T::Hash[String, T.untyped]]))) }
359
348
  def pyproject_hash_for(pyproject_content)
360
349
  SharedHelpers.in_a_temporary_directory do |dir|
361
350
  SharedHelpers.with_git_configured(credentials: credentials) do
@@ -383,20 +372,6 @@ module Dependabot
383
372
  /tool\.poetry\.#{old_req[:groups].first}\.#{escape(dep)}\](?:\r?\n).*?\s*version\s* =.*?(?:\r?\n)/m
384
373
  end
385
374
 
386
- sig { params(dep: Dependabot::Dependency, old_req: String).returns(Regexp) }
387
- def pep621_declaration_regex(dep, old_req)
388
- /(?<declaration>["']#{escape(dep)}#{extras_pattern(dep)}#{Regexp.escape(old_req)}["'])/mi
389
- end
390
-
391
- # Reconstructs extras from metadata for PEP 621 regex matching.
392
- sig { params(dep: Dependabot::Dependency).returns(String) }
393
- def extras_pattern(dep)
394
- extras_str = dep.metadata[:extras]
395
- return "" unless extras_str.is_a?(String) && !extras_str.empty?
396
-
397
- "\\[" + extras_str.split(",").map { |e| Regexp.escape(e.strip) }.join(",\\s*") + "\\]"
398
- end
399
-
400
375
  sig { params(dep: Dependency).returns(String) }
401
376
  def escape(dep)
402
377
  Regexp.escape(dep.name).gsub("\\-", "[-_.]")
@@ -443,6 +418,14 @@ module Dependabot
443
418
  )
444
419
  end
445
420
 
421
+ sig { returns(PoetryPluginInstaller) }
422
+ def poetry_plugin_installer
423
+ @poetry_plugin_installer ||= T.let(
424
+ PoetryPluginInstaller.from_dependency_files(dependency_files),
425
+ T.nilable(PoetryPluginInstaller)
426
+ )
427
+ end
428
+
446
429
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
447
430
  def pyproject
448
431
  @pyproject ||=
@@ -16,6 +16,81 @@ module Dependabot
16
16
  class PyprojectPreparer
17
17
  extend T::Sig
18
18
 
19
+ # Fixed regex for extracting the name+extras prefix from a PEP 508 entry.
20
+ # Does not interpolate library input, avoiding polynomial backtracking.
21
+ PEP508_PREFIX = T.let(
22
+ /\A(?<prefix>(?<name>[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)(?:\[[^\]]*\])?)/i,
23
+ Regexp
24
+ )
25
+
26
+ # Pins a single PEP 508 dependency entry string to a specific version,
27
+ # preserving extras and environment markers.
28
+ sig { params(entry: String, version: String).returns(String) }
29
+ def self.pin_pep508_entry(entry, version)
30
+ prefix_match = entry.match(PEP508_PREFIX)
31
+ return entry unless prefix_match
32
+
33
+ prefix = T.must(prefix_match[:prefix])
34
+ rest = T.must(entry[prefix.length..])
35
+
36
+ # Extract the environment marker ("; ..." suffix) if present
37
+ marker_match = rest.match(/(\s*;.*)/)
38
+ marker = marker_match ? marker_match[1] : ""
39
+
40
+ "#{prefix}==#{version}#{marker}"
41
+ end
42
+ private_class_method :pin_pep508_entry
43
+
44
+ # Freezes PEP 621 dependencies in-place within a parsed pyproject object.
45
+ # Replaces version specifiers with ==dep.version for each matching dep.
46
+ # Accepts an optional block to filter which dependencies to freeze.
47
+ sig do
48
+ params(
49
+ pyproject_object: T::Hash[String, T.untyped],
50
+ deps: T::Array[Dependabot::Dependency],
51
+ blk: T.nilable(T.proc.params(dep: Dependabot::Dependency).returns(T::Boolean))
52
+ ).void
53
+ end
54
+ def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
55
+ dep_arrays = collect_pep621_dep_arrays(pyproject_object)
56
+ return if dep_arrays.empty?
57
+
58
+ deps.each do |dep|
59
+ next if blk && !yield(dep)
60
+ next unless dep.version
61
+
62
+ pin_pep621_dep_in_arrays!(dep_arrays, dep)
63
+ end
64
+ end
65
+
66
+ sig { params(pyproject_object: T::Hash[String, T.untyped]).returns(T::Array[T::Array[String]]) }
67
+ def self.collect_pep621_dep_arrays(pyproject_object)
68
+ project_object = pyproject_object["project"]
69
+ return [] unless project_object
70
+
71
+ dep_arrays = [project_object["dependencies"]]
72
+ project_object["optional-dependencies"]&.each_value { |opt| dep_arrays << opt }
73
+ dep_arrays.compact!
74
+ dep_arrays.select! { |arr| arr.is_a?(Array) && arr.all?(String) }
75
+ dep_arrays
76
+ end
77
+ private_class_method :collect_pep621_dep_arrays
78
+
79
+ sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
80
+ def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
81
+ normalised_name = NameNormaliser.normalise(dep.name)
82
+ dep_arrays.each do |arr|
83
+ arr.each_with_index do |entry, i|
84
+ match = entry.match(PEP508_PREFIX)
85
+ next unless match
86
+ next unless NameNormaliser.normalise(T.must(match[:name])) == normalised_name
87
+
88
+ arr[i] = pin_pep508_entry(entry, T.must(dep.version))
89
+ end
90
+ end
91
+ end
92
+ private_class_method :pin_pep621_dep_in_arrays!
93
+
19
94
  sig { params(pyproject_content: String, lockfile: T.nilable(Dependabot::DependencyFile)).void }
20
95
  def initialize(pyproject_content:, lockfile: nil)
21
96
  @pyproject_content = pyproject_content
@@ -64,8 +139,8 @@ module Dependabot
64
139
  .gsub('#{', "{")
65
140
  end
66
141
 
67
- # rubocop:disable Metrics/PerceivedComplexity
68
- # rubocop:disable Metrics/AbcSize
142
+ UNSUPPORTED_SOURCE_TYPES = T.let(%w(directory file url).freeze, T::Array[String])
143
+
69
144
  sig { params(dependencies: T::Array[Dependabot::Dependency]).returns(String) }
70
145
  def freeze_top_level_dependencies_except(dependencies)
71
146
  return pyproject_content unless lockfile
@@ -80,39 +155,18 @@ module Dependabot
80
155
  Dependabot::Python::FileParser::PyprojectFilesParser::POETRY_DEPENDENCY_TYPES.each do |key|
81
156
  next unless poetry_object[key]
82
157
 
83
- source_types = %w(directory file url)
84
158
  poetry_object.fetch(key).each do |dep_name, _|
85
159
  next if excluded_names.include?(normalise(dep_name))
86
160
 
87
- locked_details = locked_details(dep_name)
88
-
89
- next unless (locked_version = locked_details&.fetch("version"))
90
-
91
- next if source_types.include?(locked_details.dig("source", "type"))
92
-
93
- if locked_details.dig("source", "type") == "git"
94
- poetry_object[key][dep_name] = {
95
- "git" => locked_details.dig("source", "url"),
96
- "rev" => locked_details.dig("source", "reference")
97
- }
98
- subdirectory = locked_details.dig("source", "subdirectory")
99
- poetry_object[key][dep_name]["subdirectory"] = subdirectory if subdirectory
100
- elsif poetry_object[key][dep_name].is_a?(Hash)
101
- poetry_object[key][dep_name]["version"] = locked_version
102
- elsif poetry_object[key][dep_name].is_a?(Array)
103
- # if it has multiple-constraints, locking to a single version is
104
- # going to result in a bad lockfile, ignore
105
- next
106
- else
107
- poetry_object[key][dep_name] = locked_version
108
- end
161
+ freeze_poetry_dep!(poetry_object[key], dep_name)
109
162
  end
110
163
  end
111
164
 
165
+ # Freeze PEP 621 project.dependencies and project.optional-dependencies
166
+ freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
167
+
112
168
  TomlRB.dump(pyproject_object)
113
169
  end
114
- # rubocop:enable Metrics/AbcSize
115
- # rubocop:enable Metrics/PerceivedComplexity
116
170
 
117
171
  private
118
172
 
@@ -122,6 +176,64 @@ module Dependabot
122
176
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
123
177
  attr_reader :lockfile
124
178
 
179
+ sig { params(deps_hash: T::Hash[String, T.untyped], dep_name: String).void }
180
+ def freeze_poetry_dep!(deps_hash, dep_name)
181
+ details = locked_details(dep_name)
182
+ return unless (locked_version = details&.fetch("version"))
183
+
184
+ source_type = details.dig("source", "type")
185
+ return if UNSUPPORTED_SOURCE_TYPES.include?(source_type)
186
+
187
+ if source_type == "git"
188
+ freeze_git_dep!(deps_hash, dep_name, details)
189
+ elsif deps_hash[dep_name].is_a?(Hash)
190
+ deps_hash[dep_name]["version"] = locked_version
191
+ elsif !deps_hash[dep_name].is_a?(Array)
192
+ deps_hash[dep_name] = locked_version
193
+ end
194
+ end
195
+
196
+ sig { params(deps_hash: T::Hash[String, T.untyped], dep_name: String, details: T::Hash[String, T.untyped]).void }
197
+ def freeze_git_dep!(deps_hash, dep_name, details)
198
+ deps_hash[dep_name] = {
199
+ "git" => details.dig("source", "url"),
200
+ "rev" => details.dig("source", "reference")
201
+ }
202
+ subdirectory = details.dig("source", "subdirectory")
203
+ deps_hash[dep_name]["subdirectory"] = subdirectory if subdirectory
204
+ end
205
+
206
+ sig { params(pyproject_object: T::Hash[String, T.untyped], excluded_names: T::Array[String]).void }
207
+ def freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
208
+ project_object = pyproject_object["project"]
209
+ return unless project_object
210
+
211
+ freeze_pep621_dep_array!(project_object["dependencies"], excluded_names)
212
+
213
+ project_object["optional-dependencies"]&.each_value do |opt_deps|
214
+ freeze_pep621_dep_array!(opt_deps, excluded_names)
215
+ end
216
+ end
217
+
218
+ sig { params(dep_array: T.nilable(T::Array[String]), excluded_names: T::Array[String]).void }
219
+ def freeze_pep621_dep_array!(dep_array, excluded_names)
220
+ return unless dep_array
221
+
222
+ dep_array.each_with_index do |entry, index|
223
+ # Extract dependency name from PEP 508 string
224
+ match = entry.match(/\A([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i)
225
+ next unless match
226
+
227
+ dep_name = normalise(T.must(match[1]))
228
+ next if excluded_names.include?(dep_name)
229
+
230
+ locked_details = locked_details(dep_name)
231
+ next unless (locked_version = locked_details&.fetch("version"))
232
+
233
+ dep_array[index] = self.class.send(:pin_pep508_entry, entry, locked_version)
234
+ end
235
+ end
236
+
125
237
  sig { params(dep_name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
126
238
  def locked_details(dep_name)
127
239
  parsed_lockfile.fetch("package")
@@ -86,6 +86,22 @@ module Dependabot
86
86
  def unsupported?
87
87
  false
88
88
  end
89
+
90
+ # Poetry supports requires-poetry constraints in pyproject.toml;
91
+ # other Python package managers don't have an equivalent mechanism.
92
+ sig { override.void }
93
+ def raise_if_unsupported!
94
+ super
95
+ return unless requirement
96
+ return unless version
97
+ return if T.cast(T.must(requirement).satisfied_by?(T.must(version)), T::Boolean)
98
+
99
+ raise Dependabot::ToolVersionNotSupported.new(
100
+ NAME,
101
+ version.to_s,
102
+ requirement.to_s
103
+ )
104
+ end
89
105
  end
90
106
 
91
107
  class PipCompilePackageManager < Dependabot::Ecosystem::VersionManager
@@ -0,0 +1,95 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "shellwords"
5
+ require "sorbet-runtime"
6
+ require "toml-rb"
7
+ require "dependabot/dependency_file"
8
+ require "dependabot/errors"
9
+ require "dependabot/shared_helpers"
10
+
11
+ module Dependabot
12
+ module Python
13
+ class PoetryPluginInstaller
14
+ extend T::Sig
15
+
16
+ # Only allow valid PyPI package names to prevent command injection
17
+ VALID_PLUGIN_NAME = /\A[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?\z/
18
+
19
+ # Only allow valid version constraint characters to prevent command injection
20
+ VALID_CONSTRAINT = /\A[a-zA-Z0-9.*,!=<>~^ ]+\z/
21
+
22
+ sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).returns(PoetryPluginInstaller) }
23
+ def self.from_dependency_files(dependency_files)
24
+ pyproject_content = dependency_files.find { |f| f.name == "pyproject.toml" }&.content
25
+ new(pyproject_content: pyproject_content)
26
+ end
27
+
28
+ sig { params(pyproject_content: T.nilable(String)).void }
29
+ def initialize(pyproject_content:)
30
+ @pyproject_content = T.let(pyproject_content, T.nilable(String))
31
+ @plugins_installed = T.let(false, T::Boolean)
32
+ end
33
+
34
+ sig { void }
35
+ def install_required_plugins
36
+ return if @plugins_installed
37
+
38
+ required_plugins.each do |name, constraint|
39
+ install_plugin(name, constraint)
40
+ end
41
+
42
+ @plugins_installed = true
43
+ end
44
+
45
+ private
46
+
47
+ sig { returns(T.nilable(String)) }
48
+ attr_reader :pyproject_content
49
+
50
+ sig { returns(T::Hash[String, String]) }
51
+ def required_plugins
52
+ return {} unless pyproject_content
53
+
54
+ parsed = TomlRB.parse(pyproject_content)
55
+ plugins = parsed.dig("tool", "poetry", "requires-plugins")
56
+ return {} unless plugins.is_a?(Hash)
57
+
58
+ plugins.each_with_object({}) do |(name, constraint), result|
59
+ next unless name.is_a?(String) && constraint.is_a?(String)
60
+ next unless valid_plugin_name?(name)
61
+ next unless valid_constraint?(constraint)
62
+
63
+ result[name] = constraint
64
+ end
65
+ rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
66
+ {}
67
+ end
68
+
69
+ sig { params(name: String).returns(T::Boolean) }
70
+ def valid_plugin_name?(name)
71
+ VALID_PLUGIN_NAME.match?(name)
72
+ end
73
+
74
+ sig { params(constraint: String).returns(T::Boolean) }
75
+ def valid_constraint?(constraint)
76
+ VALID_CONSTRAINT.match?(constraint)
77
+ end
78
+
79
+ sig { params(name: String, constraint: String).void }
80
+ def install_plugin(name, constraint)
81
+ Dependabot.logger.info("Installing Poetry plugin: #{name}@#{constraint}")
82
+
83
+ escaped = Shellwords.shellescape("#{name}@#{constraint}")
84
+ SharedHelpers.run_shell_command(
85
+ "pyenv exec poetry self add #{escaped}",
86
+ fingerprint: "pyenv exec poetry self add <plugin_name>@<constraint>"
87
+ )
88
+ rescue SharedHelpers::HelperSubprocessFailed => e
89
+ Dependabot.logger.warn(
90
+ "Failed to install Poetry plugin #{name}@#{constraint}: #{e.message}"
91
+ )
92
+ end
93
+ end
94
+ end
95
+ end
@@ -9,6 +9,7 @@ require "sorbet-runtime"
9
9
  require "dependabot/dependency"
10
10
  require "dependabot/git_commit_checker"
11
11
  require "dependabot/python/update_checker"
12
+ require "dependabot/update_checkers/cooldown_calculation"
12
13
  require "dependabot/update_checkers/version_filters"
13
14
  require "dependabot/registry_client"
14
15
  require "dependabot/python/authed_url_builder"
@@ -110,8 +111,9 @@ module Dependabot
110
111
  new_version = version_class.new(tag_version_str)
111
112
  days = cooldown_days_for(current_version, new_version)
112
113
 
113
- passed_seconds = Time.now.to_i - release_date_to_seconds(tag_with_detail.release_date)
114
- passed_seconds < days * DAY_IN_SECONDS
114
+ release_time = Time.at(release_date_to_seconds(tag_with_detail.release_date))
115
+ Dependabot::UpdateCheckers::CooldownCalculation
116
+ .within_cooldown_window?(release_time, days)
115
117
  end
116
118
 
117
119
  sig do
@@ -18,6 +18,7 @@ require "dependabot/python/requirement"
18
18
  require "dependabot/python/native_helpers"
19
19
  require "dependabot/python/authed_url_builder"
20
20
  require "dependabot/python/name_normaliser"
21
+ require "dependabot/python/poetry_plugin_installer"
21
22
 
22
23
  module Dependabot
23
24
  module Python
@@ -90,6 +91,7 @@ module Dependabot
90
91
  @original_reqs_resolvable = T.let(nil, T.nilable(T::Boolean))
91
92
  @python_requirement_parser = T.let(nil, T.nilable(FileParser::PythonRequirementParser))
92
93
  @language_version_manager = T.let(nil, T.nilable(LanguageVersionManager))
94
+ @poetry_plugin_installer = T.let(nil, T.nilable(PoetryPluginInstaller))
93
95
  end
94
96
 
95
97
  sig { params(requirement: T.nilable(String)).returns(T.nilable(Dependabot::Python::Version)) }
@@ -129,6 +131,9 @@ module Dependabot
129
131
 
130
132
  language_version_manager.install_required_python
131
133
 
134
+ # Install any required Poetry plugins declared in pyproject.toml
135
+ poetry_plugin_installer.install_required_plugins
136
+
132
137
  # use system git instead of the pure Python dulwich
133
138
  run_poetry_command("pyenv exec poetry config system-git-client true")
134
139
 
@@ -385,6 +390,14 @@ module Dependabot
385
390
  poetry_lock
386
391
  end
387
392
 
393
+ sig { returns(PoetryPluginInstaller) }
394
+ def poetry_plugin_installer
395
+ @poetry_plugin_installer ||= T.let(
396
+ PoetryPluginInstaller.from_dependency_files(dependency_files),
397
+ T.nilable(PoetryPluginInstaller)
398
+ )
399
+ end
400
+
388
401
  sig { params(command: String, fingerprint: T.nilable(String)).returns(String) }
389
402
  def run_poetry_command(command, fingerprint: nil)
390
403
  SharedHelpers.run_shell_command(command, fingerprint: fingerprint)