dependabot-python 0.369.0 → 0.371.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64cdd01e07bc2f6472a1027884006af4c7df3648168f04135c7b91cdf5530f14
4
- data.tar.gz: b5610dc0420858f42cb23a8462abcd7ebd4b9b1aab8be3ca28233a99c239982c
3
+ metadata.gz: e5c59beb6b677e51deac37d95023e448ad921265030b47910e4477249a07d1b4
4
+ data.tar.gz: 4c91924b433ac4f54c2f4f1bfd649e1124864b789cc459e4ab0aca281c02b603
5
5
  SHA512:
6
- metadata.gz: f4cb556708bba8d54dc3d04495d021e4808cf5651184ed9fe2d1b65d6b1114c688f92d9be8ab8fae6e96d818643ce4c4824db5971dc2fabf55370b6ffff26f39
7
- data.tar.gz: 64514c6fad289e34af6bdbaeecc25f8ff815ba193d77088a86fa3399c9379ab1cad740ff0f18ae0ac176e85375714e15f36ceae638b96d6e2714f425ce86cd96
6
+ metadata.gz: ec8aa99e69214eec367ee3dff6e9e4b6f2031f75cca5e7b674eaa41369c8f40e5af4b8ba86dce39420e7affc0316301bacfc0cf2c832902d3d3da22558971d19
7
+ data.tar.gz: 586dea07cef16ec324974e7983dfbe42314e4782bfb388194bb524dc78ccb8a3448de97b52867e20be55b5304f674ce3eec26ec6f5e6be30632e4b3e1ffdff34
@@ -32,6 +32,30 @@ def parse_pep621_pep735_dependencies(pyproject_path):
32
32
  next(iter(specifier_set)).operator in {"==", "==="}):
33
33
  return next(iter(specifier_set)).version
34
34
 
35
+ def original_requirement_from_entry(entry, req):
36
+ """Extract the original requirement string.
37
+
38
+ The packaging library normalizes specifiers
39
+ (removes spaces, reorders operators), but we
40
+ need the original so the file updater regex
41
+ can match the file content.
42
+ """
43
+ # Strip the name (and any extras like [filecache]) from the start
44
+ remainder = entry[len(req.name):].strip()
45
+
46
+ # Strip extras bracket if present, e.g. [filecache]
47
+ if remainder.startswith("["):
48
+ close = remainder.index("]")
49
+ remainder = remainder[close + 1:].strip()
50
+
51
+ # Strip markers from the end, e.g. "; python_version < '3.4'"
52
+ if req.marker:
53
+ marker_pos = remainder.find(";")
54
+ if marker_pos != -1:
55
+ remainder = remainder[:marker_pos].strip()
56
+
57
+ return remainder
58
+
35
59
  def parse_requirement(entry, pyproject_path, requirement_type=None):
36
60
  try:
37
61
  req = Requirement(entry)
@@ -45,6 +69,8 @@ def parse_pep621_pep735_dependencies(pyproject_path):
45
69
  "markers": str(req.marker) or None,
46
70
  "file": pyproject_path,
47
71
  "requirement": str(req.specifier),
72
+ "source_requirement":
73
+ original_requirement_from_entry(entry, req),
48
74
  "extras": sorted(list(req.extras)),
49
75
  "requirement_type": requirement_type,
50
76
  }
@@ -0,0 +1,10 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+
5
+ dependencies = [
6
+ "requests >= 2.13.0, < 3.0",
7
+ "urllib3 == 1.26.0",
8
+ "cachecontrol[filecache] >= 0.14.0",
9
+ "idna >= 2.5 ; python_version < '3.0'",
10
+ ]
@@ -182,3 +182,84 @@ class TestEdgeCases:
182
182
  # Both groups should be processed without infinite recursion
183
183
  assert "requests" in names
184
184
  assert "flask" in names
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # source_requirement — preserves original specifier string from the TOML
189
+ # ---------------------------------------------------------------------------
190
+ class TestSourceRequirement:
191
+ def test_simple_specifier_preserves_order(self):
192
+ deps = parse("pep621_dependencies.toml")
193
+ requests = find_dep(deps, "requests")
194
+ # Original: "requests>=2.13.0,<3.0" — order preserved
195
+ assert requests["source_requirement"] == ">=2.13.0,<3.0"
196
+ # Normalized by packaging: "<3.0,>=2.13.0" (alphabetical by operator)
197
+ assert requests["requirement"] == "<3.0,>=2.13.0"
198
+
199
+ def test_exact_version(self):
200
+ deps = parse("pep621_dependencies.toml")
201
+ urllib3 = find_dep(deps, "urllib3")
202
+ assert urllib3["source_requirement"] == "==1.26.0"
203
+
204
+ def test_extras_stripped_from_source(self):
205
+ deps = parse("pep621_extras.toml")
206
+ cc = find_dep(deps, "cachecontrol")
207
+ assert cc["source_requirement"] == ">=0.14.0"
208
+
209
+ def test_markers_stripped_from_source(self):
210
+ deps = parse("pep621_markers.toml")
211
+ requests = find_dep(deps, "requests")
212
+ assert requests["source_requirement"] == ">=2.13.0"
213
+
214
+ def test_no_version_specifier(self):
215
+ deps = parse("pep621_no_version.toml")
216
+ requests = find_dep(deps, "requests")
217
+ assert requests["source_requirement"] == ""
218
+
219
+ def test_multiple_extras_stripped(self):
220
+ deps = parse("pep621_multiple_extras.toml")
221
+ boto3 = find_dep(deps, "boto3")
222
+ assert boto3["source_requirement"] == ">=1.28.0"
223
+
224
+ def test_arbitrary_equality(self):
225
+ deps = parse("pep621_arbitrary_equality.toml")
226
+ numpy = find_dep(deps, "numpy")
227
+ assert numpy["source_requirement"] == "===1.24.0rc1"
228
+
229
+ def test_spaced_specifiers_preserved(self):
230
+ deps = parse("pep621_spaced_specifiers.toml")
231
+ requests = find_dep(deps, "requests")
232
+ # Spaces are preserved from the original entry
233
+ assert requests["source_requirement"] == ">= 2.13.0, < 3.0"
234
+
235
+ def test_spaced_exact_version_preserved(self):
236
+ deps = parse("pep621_spaced_specifiers.toml")
237
+ urllib3 = find_dep(deps, "urllib3")
238
+ assert urllib3["source_requirement"] == "== 1.26.0"
239
+
240
+ def test_spaced_extras_and_specifier(self):
241
+ deps = parse("pep621_spaced_specifiers.toml")
242
+ cc = find_dep(deps, "cachecontrol")
243
+ assert cc["source_requirement"] == ">= 0.14.0"
244
+
245
+ def test_spaced_specifier_with_markers(self):
246
+ deps = parse("pep621_spaced_specifiers.toml")
247
+ idna = find_dep(deps, "idna")
248
+ # Markers stripped, original spacing kept
249
+ assert idna["source_requirement"] == ">= 2.5"
250
+
251
+ def test_optional_deps_with_spaces(self):
252
+ deps = parse("pep621_dependencies.toml")
253
+ pysocks = find_dep(deps, "PySocks")
254
+ # Original: "PySocks >= 1.5.6, != 1.5.7, < 2"
255
+ assert pysocks["source_requirement"] == ">= 1.5.6, != 1.5.7, < 2"
256
+
257
+ def test_build_system_source_requirement(self):
258
+ deps = parse("pep621_dependencies.toml")
259
+ setuptools = find_dep(deps, "setuptools")
260
+ assert setuptools["source_requirement"] == ">=68.0"
261
+
262
+ def test_dependency_group_source_requirement(self):
263
+ deps = parse("pep735_dependency_groups.toml")
264
+ pytest_dep = find_dep(deps, "pytest")
265
+ assert pytest_dep["source_requirement"] == "==7.1.3"
@@ -7,6 +7,7 @@ require "dependabot/dependency_file"
7
7
  require "dependabot/shared_helpers"
8
8
  require "dependabot/python/file_parser/python_requirement_parser"
9
9
  require "dependabot/python/language_version_manager"
10
+ require "dependabot/python/poetry_plugin_installer"
10
11
 
11
12
  module Dependabot
12
13
  module Python
@@ -33,6 +34,10 @@ module Dependabot
33
34
  SharedHelpers.with_git_configured(credentials: credentials) do
34
35
  write_temporary_files
35
36
  language_version_manager.install_required_python
37
+
38
+ # Install any required Poetry plugins declared in pyproject.toml
39
+ poetry_plugin_installer.install_required_plugins
40
+
36
41
  run_poetry_lock
37
42
  read_generated_lockfile
38
43
  end
@@ -119,6 +124,14 @@ module Dependabot
119
124
  )
120
125
  end
121
126
 
127
+ sig { returns(PoetryPluginInstaller) }
128
+ def poetry_plugin_installer
129
+ @poetry_plugin_installer ||= T.let(
130
+ PoetryPluginInstaller.from_dependency_files(dependency_files),
131
+ T.nilable(PoetryPluginInstaller)
132
+ )
133
+ end
134
+
122
135
  sig { params(error: SharedHelpers::HelperSubprocessFailed).void }
123
136
  def handle_generation_error(error)
124
137
  Dependabot.logger.error(
@@ -25,6 +25,7 @@ module Dependabot
25
25
  sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).void }
26
26
  def initialize(dependency_files:)
27
27
  @dependency_files = dependency_files
28
+ @dynamic_fields = T.let(nil, T.nilable(T::Array[String]))
28
29
  end
29
30
 
30
31
  sig { returns(Dependabot::FileParsers::Base::DependencySet) }
@@ -68,7 +69,10 @@ module Dependabot
68
69
 
69
70
  groups = T.must(poetry_root)["group"] || {}
70
71
  groups.each do |group, group_spec|
71
- dependencies += parse_poetry_dependency_group(group, group_spec["dependencies"])
72
+ deps = group_spec["dependencies"]
73
+ next unless deps
74
+
75
+ dependencies += parse_poetry_dependency_group(group, deps)
72
76
  end
73
77
  dependencies
74
78
  end
@@ -84,16 +88,7 @@ module Dependabot
84
88
  return dependencies if using_pdm?
85
89
 
86
90
  parse_pep621_pep735_dependencies.each do |dep|
87
- # If a requirement has a `<` or `<=` marker then updating it is
88
- # probably blocked. Ignore it.
89
- next if dep["markers"]&.include?("<")
90
-
91
- # If no requirement, don't add it
92
- next if dep["requirement"].empty?
93
-
94
- # Skip build-system.requires dependencies when using Poetry
95
- # Poetry manages its own build system dependencies
96
- next if using_poetry? && dep["requirement_type"] == "build-system.requires"
91
+ next if skip_pep621_dep?(dep)
97
92
 
98
93
  dependencies <<
99
94
  Dependency.new(
@@ -106,7 +101,9 @@ module Dependabot
106
101
  groups: [dep["requirement_type"]].compact
107
102
  }],
108
103
  package_manager: "pip",
109
- metadata: extras_metadata(dep["extras"])
104
+ metadata: extras_metadata(dep["extras"]).merge(
105
+ source_requirement: dep["source_requirement"]
106
+ ).compact
110
107
  )
111
108
  end
112
109
 
@@ -229,6 +226,43 @@ module Dependabot
229
226
  using_pep621? && pdm_lock
230
227
  end
231
228
 
229
+ sig { returns(T::Array[String]) }
230
+ def dynamic_fields
231
+ @dynamic_fields ||= parsed_pyproject.dig("project", "dynamic") || []
232
+ end
233
+
234
+ sig { params(dep: T::Hash[String, T.untyped]).returns(T::Boolean) }
235
+ def skip_pep621_dep?(dep)
236
+ # If a requirement has a `<` or `<=` marker then updating it is
237
+ # probably blocked. Ignore it.
238
+ return true if dep["markers"]&.include?("<")
239
+
240
+ # If no requirement, don't add it
241
+ return true if dep["requirement"].empty?
242
+
243
+ # Skip build-system.requires dependencies when using Poetry
244
+ # Poetry manages its own build system dependencies
245
+ return true if using_poetry? && dep["requirement_type"] == "build-system.requires"
246
+
247
+ # When dependencies or optional-dependencies are listed in project.dynamic,
248
+ # they are managed by the build backend (e.g. Poetry) — skip the PEP 621 path
249
+ dynamic_pep621_dep?(dep["requirement_type"])
250
+ end
251
+
252
+ sig { params(requirement_type: T.nilable(String)).returns(T::Boolean) }
253
+ def dynamic_pep621_dep?(requirement_type)
254
+ return false unless using_poetry?
255
+ return false unless requirement_type
256
+
257
+ if requirement_type == "dependencies"
258
+ dynamic_fields.include?("dependencies")
259
+ elsif parsed_pyproject.dig("project", "optional-dependencies")&.key?(requirement_type)
260
+ dynamic_fields.include?("optional-dependencies")
261
+ else
262
+ false
263
+ end
264
+ end
265
+
232
266
  # Create a DependencySet where each element has no requirement. Any
233
267
  # requirements will be added when combining the DependencySet with
234
268
  # other DependencySets.
@@ -1,6 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "toml-rb"
4
5
  require "dependabot/dependency"
5
6
  require "dependabot/file_parsers"
6
7
  require "dependabot/file_parsers/base"
@@ -110,7 +111,13 @@ module Dependabot
110
111
 
111
112
  return PipenvPackageManager.new(T.must(detect_pipenv_version)) if detect_pipenv_version
112
113
 
113
- return PoetryPackageManager.new(T.must(detect_poetry_version)) if detect_poetry_version
114
+ poetry_version = detect_poetry_version
115
+ if poetry_version
116
+ return PoetryPackageManager.new(
117
+ poetry_version,
118
+ requires_poetry_version_constraint
119
+ )
120
+ end
114
121
 
115
122
  return PipCompilePackageManager.new(T.must(detect_pipcompile_version)) if detect_pipcompile_version
116
123
 
@@ -135,6 +142,19 @@ module Dependabot
135
142
  nil
136
143
  end
137
144
 
145
+ sig { returns(T.nilable(Dependabot::Python::Requirement)) }
146
+ def requires_poetry_version_constraint
147
+ return nil unless pyproject&.content
148
+
149
+ parsed = TomlRB.parse(T.must(pyproject).content)
150
+ constraint = parsed.dig("tool", "poetry", "requires-poetry")
151
+ return nil unless constraint.is_a?(String) && !constraint.strip.empty?
152
+
153
+ Dependabot::Python::Requirement.new(constraint.strip)
154
+ rescue TomlRB::ParseError, TomlRB::ValueOverwriteError, Gem::Requirement::BadRequirementError
155
+ nil
156
+ end
157
+
138
158
  # Detects the version of pip-compile. If the version cannot be detected, it returns nil
139
159
  sig { returns(T.nilable(String)) }
140
160
  def detect_pipcompile_version
@@ -0,0 +1,162 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/python/file_updater/poetry_file_updater"
6
+
7
+ module Dependabot
8
+ module Python
9
+ class FileUpdater
10
+ class PoetryFileUpdater
11
+ class Pep621Updater
12
+ extend T::Sig
13
+
14
+ sig { params(dep: Dependabot::Dependency).void }
15
+ def initialize(dep:)
16
+ @dep = dep
17
+ end
18
+
19
+ sig do
20
+ params(
21
+ content: String,
22
+ new_r: T::Hash[Symbol, T.untyped],
23
+ old_r: T::Hash[Symbol, T.untyped]
24
+ ).returns(T.nilable(String))
25
+ end
26
+ def replace(content, new_r, old_r)
27
+ source_req = dep.metadata[:source_requirement]
28
+
29
+ if source_req
30
+ replace_with_source_requirement(content, source_req, new_r, old_r)
31
+ else
32
+ replace_with_normalized_requirement(content, new_r, old_r)
33
+ end
34
+ end
35
+
36
+ sig { params(source_req: String, old_req: String, new_req: String).returns(String) }
37
+ def rewrite_pep508_requirement(source_req, old_req, new_req)
38
+ old_specifiers = parse_specifiers(old_req)
39
+ new_specifiers = parse_specifiers(new_req)
40
+
41
+ old_versions_by_op = group_versions_by_operator(old_specifiers)
42
+ new_versions_by_op = group_versions_by_operator(new_specifiers)
43
+
44
+ replacements = T.let([], T::Array[T::Hash[Symbol, String]])
45
+ new_versions_by_op.each do |operator, new_versions|
46
+ old_versions = old_versions_by_op[operator]
47
+ next unless old_versions
48
+ next unless old_versions.length == new_versions.length
49
+
50
+ old_versions.zip(new_versions).each do |old_version, new_version|
51
+ next if old_version == new_version
52
+
53
+ replacements << {
54
+ operator: operator,
55
+ old_version: old_version,
56
+ new_version: T.must(new_version)
57
+ }
58
+ end
59
+ end
60
+
61
+ result = source_req.dup
62
+ replacements.each do |replacement|
63
+ op = Regexp.escape(T.must(replacement[:operator]))
64
+ ver = Regexp.escape(T.must(replacement[:old_version]))
65
+ result = result.sub(/(#{op}\s*)#{ver}/, "\\1#{replacement[:new_version]}")
66
+ end
67
+ result
68
+ end
69
+
70
+ sig { params(specifiers: T::Array[T::Hash[Symbol, String]]).returns(T::Hash[String, T::Array[String]]) }
71
+ def group_versions_by_operator(specifiers)
72
+ specifiers.each_with_object(
73
+ T.let({}, T::Hash[String, T::Array[String]])
74
+ ) do |specifier, grouped_versions|
75
+ operator = T.must(specifier[:operator])
76
+ version = T.must(specifier[:version])
77
+
78
+ grouped_versions[operator] ||= []
79
+ T.must(grouped_versions[operator]) << version
80
+ end
81
+ end
82
+
83
+ sig { params(req: String).returns(T::Array[T::Hash[Symbol, String]]) }
84
+ def parse_specifiers(req)
85
+ req.scan(/([!<>=~]+)\s*([^\s,]+)/).map do |op, ver|
86
+ { operator: op, version: ver }
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ sig { returns(Dependabot::Dependency) }
93
+ attr_reader :dep
94
+
95
+ sig do
96
+ params(
97
+ content: String,
98
+ source_req: String,
99
+ new_r: T::Hash[Symbol, T.untyped],
100
+ old_r: T::Hash[Symbol, T.untyped]
101
+ ).returns(T.nilable(String))
102
+ end
103
+ def replace_with_source_requirement(content, source_req, new_r, old_r)
104
+ match = content.match(declaration_regex(source_req))
105
+ return unless match
106
+
107
+ declaration = T.must(match[:declaration])
108
+ new_req_str = rewrite_pep508_requirement(source_req, old_r[:requirement], new_r[:requirement])
109
+ content.sub(declaration, declaration.sub(source_req, new_req_str))
110
+ end
111
+
112
+ # Fallback when source_requirement metadata is absent (e.g. after
113
+ # DependencySet merge or deserialization). Matches using the
114
+ # normalized requirement string, which may fail on whitespace
115
+ # differences but is better than skipping the update entirely.
116
+ sig do
117
+ params(
118
+ content: String,
119
+ new_r: T::Hash[Symbol, T.untyped],
120
+ old_r: T::Hash[Symbol, T.untyped]
121
+ ).returns(T.nilable(String))
122
+ end
123
+ def replace_with_normalized_requirement(content, new_r, old_r)
124
+ old_req = old_r[:requirement]
125
+ new_req = new_r[:requirement]
126
+
127
+ match = content.match(normalized_declaration_regex(old_req))
128
+ return unless match
129
+
130
+ declaration = T.must(match[:declaration])
131
+ return unless declaration.include?(old_req)
132
+
133
+ content.sub(declaration, declaration.sub(old_req, new_req))
134
+ end
135
+
136
+ sig { params(old_req: String).returns(Regexp) }
137
+ def declaration_regex(old_req)
138
+ /(?<declaration>["']#{escape}\s*#{extras_pattern}\s*#{Regexp.escape(old_req)}["'])/mi
139
+ end
140
+
141
+ sig { params(old_req: String).returns(Regexp) }
142
+ def normalized_declaration_regex(old_req)
143
+ /(?<declaration>["']#{escape}#{extras_pattern}#{Regexp.escape(old_req)}["'])/mi
144
+ end
145
+
146
+ sig { returns(String) }
147
+ def extras_pattern
148
+ extras_str = dep.metadata[:extras]
149
+ return "" unless extras_str.is_a?(String) && !extras_str.empty?
150
+
151
+ "\\[" + extras_str.split(",").map { |e| Regexp.escape(e.strip) }.join(",\\s*") + "\\]"
152
+ end
153
+
154
+ sig { returns(String) }
155
+ def escape
156
+ Regexp.escape(dep.name).gsub("\\-", "[-_.]")
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -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,52 +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
- 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
131
- end
126
+ return unless declaration_match
132
127
 
133
- # Try Poetry table format
134
- table_match = content.match(table_declaration_regex(dep, new_r))
135
- if table_match
136
- return content.gsub(table_declaration_regex(dep, new_r)) do |match|
137
- match.gsub(
138
- /(\s*version\s*=\s*["'])#{Regexp.escape(old_req)}/,
139
- '\1' + new_req
140
- )
141
- end
142
- 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
134
+
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)
143
142
 
144
- # Try PEP 621 array format (e.g., dependencies = ["django==5.0.0"])
145
- pep621_regex = pep621_declaration_regex(dep, old_req)
146
- pep621_match = content.match(pep621_regex)
147
- if pep621_match
148
- declaration = pep621_match[:declaration]
149
- new_declaration = T.must(declaration).sub(old_req, new_req)
150
- return content.sub(T.must(declaration), new_declaration)
143
+ content.gsub(regex) do |match|
144
+ match.gsub(
145
+ /(\s*version\s*=\s*["'])#{Regexp.escape(old_req)}/,
146
+ '\1' + new_req
147
+ )
151
148
  end
149
+ end
152
150
 
153
- 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)
154
154
  end
155
155
 
156
156
  sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
@@ -158,14 +158,7 @@ module Dependabot
158
158
  req.dig(:source, :type) == "git"
159
159
  end
160
160
 
161
- sig do
162
- params(
163
- dep: Dependabot::Dependency,
164
- content: String,
165
- new_r: T::Hash[Symbol, T.untyped],
166
- old_r: T::Hash[Symbol, T.untyped]
167
- ).returns(String)
168
- 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) }
169
162
  def update_git_tag(dep, content, new_r, old_r)
170
163
  old_tag = old_r.dig(:source, :ref)
171
164
  new_tag = new_r.dig(:source, :ref)
@@ -302,6 +295,7 @@ module Dependabot
302
295
  add_auth_env_vars
303
296
 
304
297
  language_version_manager.install_required_python
298
+ poetry_plugin_installer.install_required_plugins
305
299
 
306
300
  # use system git instead of the pure Python dulwich
307
301
  run_poetry_command("pyenv exec poetry config system-git-client true")
@@ -350,17 +344,7 @@ module Dependabot
350
344
  .add_auth_env_vars(credentials)
351
345
  end
352
346
 
353
- sig do
354
- params(
355
- pyproject_content: String
356
- ).returns(T.nilable(
357
- T.any(
358
- T::Hash[String, T.untyped],
359
- String,
360
- T::Array[T::Hash[String, T.untyped]]
361
- )
362
- ))
363
- 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]]))) }
364
348
  def pyproject_hash_for(pyproject_content)
365
349
  SharedHelpers.in_a_temporary_directory do |dir|
366
350
  SharedHelpers.with_git_configured(credentials: credentials) do
@@ -388,20 +372,6 @@ module Dependabot
388
372
  /tool\.poetry\.#{old_req[:groups].first}\.#{escape(dep)}\](?:\r?\n).*?\s*version\s* =.*?(?:\r?\n)/m
389
373
  end
390
374
 
391
- sig { params(dep: Dependabot::Dependency, old_req: String).returns(Regexp) }
392
- def pep621_declaration_regex(dep, old_req)
393
- /(?<declaration>["']#{escape(dep)}#{extras_pattern(dep)}#{Regexp.escape(old_req)}["'])/mi
394
- end
395
-
396
- # Reconstructs extras from metadata for PEP 621 regex matching.
397
- sig { params(dep: Dependabot::Dependency).returns(String) }
398
- def extras_pattern(dep)
399
- extras_str = dep.metadata[:extras]
400
- return "" unless extras_str.is_a?(String) && !extras_str.empty?
401
-
402
- "\\[" + extras_str.split(",").map { |e| Regexp.escape(e.strip) }.join(",\\s*") + "\\]"
403
- end
404
-
405
375
  sig { params(dep: Dependency).returns(String) }
406
376
  def escape(dep)
407
377
  Regexp.escape(dep.name).gsub("\\-", "[-_.]")
@@ -448,6 +418,14 @@ module Dependabot
448
418
  )
449
419
  end
450
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
+
451
429
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
452
430
  def pyproject
453
431
  @pyproject ||=