dependabot-python 0.369.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64cdd01e07bc2f6472a1027884006af4c7df3648168f04135c7b91cdf5530f14
4
- data.tar.gz: b5610dc0420858f42cb23a8462abcd7ebd4b9b1aab8be3ca28233a99c239982c
3
+ metadata.gz: 67c1d482dda68f38a7bbc7ea74f0d4cc206debd5c3f0704905911b05f04e4b2f
4
+ data.tar.gz: bb39f9c0055c2ab9a86d8a2138676c69fbac55e52c45a7a261b7af5b94c28042
5
5
  SHA512:
6
- metadata.gz: f4cb556708bba8d54dc3d04495d021e4808cf5651184ed9fe2d1b65d6b1114c688f92d9be8ab8fae6e96d818643ce4c4824db5971dc2fabf55370b6ffff26f39
7
- data.tar.gz: 64514c6fad289e34af6bdbaeecc25f8ff815ba193d77088a86fa3399c9379ab1cad740ff0f18ae0ac176e85375714e15f36ceae638b96d6e2714f425ce86cd96
6
+ metadata.gz: 9d0def98050153cafb8f4ce61c39495fd8b24f82f4ca8a4d62f158b86bfa9a695016520b92c4b54025d1df9a5c98be6eb5bba723e54846e3c449feee86d7fa28
7
+ data.tar.gz: 715ae0908b35409adb0a67afb7aa5c6ca1cbb6036ae596719a8ef5afbe3b326571850a4b026820541e56c1ad70afd9a960cf7a796cf4a1b1986f2e4bb26531b6
@@ -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) }
@@ -84,16 +85,7 @@ module Dependabot
84
85
  return dependencies if using_pdm?
85
86
 
86
87
  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"
88
+ next if skip_pep621_dep?(dep)
97
89
 
98
90
  dependencies <<
99
91
  Dependency.new(
@@ -106,7 +98,9 @@ module Dependabot
106
98
  groups: [dep["requirement_type"]].compact
107
99
  }],
108
100
  package_manager: "pip",
109
- metadata: extras_metadata(dep["extras"])
101
+ metadata: extras_metadata(dep["extras"]).merge(
102
+ source_requirement: dep["source_requirement"]
103
+ ).compact
110
104
  )
111
105
  end
112
106
 
@@ -229,6 +223,43 @@ module Dependabot
229
223
  using_pep621? && pdm_lock
230
224
  end
231
225
 
226
+ sig { returns(T::Array[String]) }
227
+ def dynamic_fields
228
+ @dynamic_fields ||= parsed_pyproject.dig("project", "dynamic") || []
229
+ end
230
+
231
+ sig { params(dep: T::Hash[String, T.untyped]).returns(T::Boolean) }
232
+ def skip_pep621_dep?(dep)
233
+ # If a requirement has a `<` or `<=` marker then updating it is
234
+ # probably blocked. Ignore it.
235
+ return true if dep["markers"]&.include?("<")
236
+
237
+ # If no requirement, don't add it
238
+ return true if dep["requirement"].empty?
239
+
240
+ # Skip build-system.requires dependencies when using Poetry
241
+ # Poetry manages its own build system dependencies
242
+ return true if using_poetry? && dep["requirement_type"] == "build-system.requires"
243
+
244
+ # When dependencies or optional-dependencies are listed in project.dynamic,
245
+ # they are managed by the build backend (e.g. Poetry) — skip the PEP 621 path
246
+ dynamic_pep621_dep?(dep["requirement_type"])
247
+ end
248
+
249
+ sig { params(requirement_type: T.nilable(String)).returns(T::Boolean) }
250
+ def dynamic_pep621_dep?(requirement_type)
251
+ return false unless using_poetry?
252
+ return false unless requirement_type
253
+
254
+ if requirement_type == "dependencies"
255
+ dynamic_fields.include?("dependencies")
256
+ elsif parsed_pyproject.dig("project", "optional-dependencies")&.key?(requirement_type)
257
+ dynamic_fields.include?("optional-dependencies")
258
+ else
259
+ false
260
+ end
261
+ end
262
+
232
263
  # Create a DependencySet where each element has no requirement. Any
233
264
  # requirements will be added when combining the DependencySet with
234
265
  # 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 ||=
@@ -16,29 +16,28 @@ module Dependabot
16
16
  class PyprojectPreparer
17
17
  extend T::Sig
18
18
 
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
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
+ )
26
25
 
27
26
  # Pins a single PEP 508 dependency entry string to a specific version,
28
27
  # 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
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}"
42
41
  end
43
42
  private_class_method :pin_pep508_entry
44
43
 
@@ -79,12 +78,14 @@ module Dependabot
79
78
 
80
79
  sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
81
80
  def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
82
- name_pattern = pep508_name_pattern(dep.name)
81
+ normalised_name = NameNormaliser.normalise(dep.name)
83
82
  dep_arrays.each do |arr|
84
83
  arr.each_with_index do |entry, i|
85
- next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*(\z|[><=!~;,])/i)
84
+ match = entry.match(PEP508_PREFIX)
85
+ next unless match
86
+ next unless NameNormaliser.normalise(T.must(match[:name])) == normalised_name
86
87
 
87
- arr[i] = pin_pep508_entry(entry, name_pattern, T.must(dep.version))
88
+ arr[i] = pin_pep508_entry(entry, T.must(dep.version))
88
89
  end
89
90
  end
90
91
  end
@@ -138,8 +139,8 @@ module Dependabot
138
139
  .gsub('#{', "{")
139
140
  end
140
141
 
141
- # rubocop:disable Metrics/PerceivedComplexity
142
- # rubocop:disable Metrics/AbcSize
142
+ UNSUPPORTED_SOURCE_TYPES = T.let(%w(directory file url).freeze, T::Array[String])
143
+
143
144
  sig { params(dependencies: T::Array[Dependabot::Dependency]).returns(String) }
144
145
  def freeze_top_level_dependencies_except(dependencies)
145
146
  return pyproject_content unless lockfile
@@ -154,32 +155,10 @@ module Dependabot
154
155
  Dependabot::Python::FileParser::PyprojectFilesParser::POETRY_DEPENDENCY_TYPES.each do |key|
155
156
  next unless poetry_object[key]
156
157
 
157
- source_types = %w(directory file url)
158
158
  poetry_object.fetch(key).each do |dep_name, _|
159
159
  next if excluded_names.include?(normalise(dep_name))
160
160
 
161
- locked_details = locked_details(dep_name)
162
-
163
- next unless (locked_version = locked_details&.fetch("version"))
164
-
165
- next if source_types.include?(locked_details.dig("source", "type"))
166
-
167
- if locked_details.dig("source", "type") == "git"
168
- poetry_object[key][dep_name] = {
169
- "git" => locked_details.dig("source", "url"),
170
- "rev" => locked_details.dig("source", "reference")
171
- }
172
- subdirectory = locked_details.dig("source", "subdirectory")
173
- poetry_object[key][dep_name]["subdirectory"] = subdirectory if subdirectory
174
- elsif poetry_object[key][dep_name].is_a?(Hash)
175
- poetry_object[key][dep_name]["version"] = locked_version
176
- elsif poetry_object[key][dep_name].is_a?(Array)
177
- # if it has multiple-constraints, locking to a single version is
178
- # going to result in a bad lockfile, ignore
179
- next
180
- else
181
- poetry_object[key][dep_name] = locked_version
182
- end
161
+ freeze_poetry_dep!(poetry_object[key], dep_name)
183
162
  end
184
163
  end
185
164
 
@@ -188,8 +167,6 @@ module Dependabot
188
167
 
189
168
  TomlRB.dump(pyproject_object)
190
169
  end
191
- # rubocop:enable Metrics/AbcSize
192
- # rubocop:enable Metrics/PerceivedComplexity
193
170
 
194
171
  private
195
172
 
@@ -199,6 +176,33 @@ module Dependabot
199
176
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
200
177
  attr_reader :lockfile
201
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
+
202
206
  sig { params(pyproject_object: T::Hash[String, T.untyped], excluded_names: T::Array[String]).void }
203
207
  def freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
204
208
  project_object = pyproject_object["project"]
@@ -226,8 +230,7 @@ module Dependabot
226
230
  locked_details = locked_details(dep_name)
227
231
  next unless (locked_version = locked_details&.fetch("version"))
228
232
 
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)
233
+ dep_array[index] = self.class.send(:pin_pep508_entry, entry, locked_version)
231
234
  end
232
235
  end
233
236
 
@@ -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
@@ -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)
@@ -11,11 +11,13 @@ require "dependabot/requirements_update_strategy"
11
11
  module Dependabot
12
12
  module Python
13
13
  class UpdateChecker
14
+ # rubocop:disable Metrics/ClassLength
14
15
  class RequirementsUpdater
15
16
  extend T::Sig
16
17
 
17
18
  PYPROJECT_OR_SEPARATOR = T.let(/(?<=[a-zA-Z0-9*])\s*\|+/, Regexp)
18
19
  PYPROJECT_SEPARATOR = T.let(/#{PYPROJECT_OR_SEPARATOR}|,/, Regexp)
20
+ LOWER_BOUND_OPS = T.let(%w(> >=).freeze, T::Array[String])
19
21
 
20
22
  class UnfixableRequirement < StandardError; end
21
23
 
@@ -111,13 +113,25 @@ module Dependabot
111
113
  def updated_pyproject_requirement(req)
112
114
  return req unless latest_resolvable_version
113
115
  return req unless req.fetch(:requirement)
114
- return req if new_version_satisfies?(req) && !has_lockfile
116
+ return req if skip_pyproject_update?(req)
115
117
 
118
+ pyproject_update_for_strategy(req)
119
+ rescue UnfixableRequirement
120
+ req.merge(requirement: :unfixable)
121
+ end
122
+
123
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
124
+ def skip_pyproject_update?(req)
125
+ new_version_satisfies?(req) && !has_lockfile &&
126
+ update_strategy != RequirementsUpdateStrategy::BumpVersions
127
+ end
128
+
129
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
130
+ def pyproject_update_for_strategy(req)
116
131
  # If the requirement uses || syntax then we always want to widen it
117
132
  return widen_pyproject_requirement(req) if req.fetch(:requirement).match?(PYPROJECT_OR_SEPARATOR)
118
133
 
119
- # If the requirement is a development dependency we always want to
120
- # bump it
134
+ # If the requirement is a development dependency we always want to bump it
121
135
  return update_pyproject_version(req) if req.fetch(:groups).include?("dev-dependencies")
122
136
 
123
137
  case update_strategy
@@ -126,37 +140,40 @@ module Dependabot
126
140
  when RequirementsUpdateStrategy::BumpVersionsIfNecessary then update_pyproject_version_if_needed(req)
127
141
  else raise "Unexpected update strategy: #{update_strategy}"
128
142
  end
129
- rescue UnfixableRequirement
130
- req.merge(requirement: :unfixable)
131
143
  end
132
144
 
133
145
  sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
134
146
  def update_pyproject_version_if_needed(req)
135
147
  return req if new_version_satisfies?(req)
136
148
 
137
- update_pyproject_version(req)
149
+ update_pyproject_version_core(req, bump_lower_bound: false)
138
150
  end
139
151
 
140
152
  sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
141
153
  def update_pyproject_version(req)
154
+ return req if req[:requirement] == "*"
155
+
156
+ update_pyproject_version_core(req, bump_lower_bound: true)
157
+ end
158
+
159
+ sig do
160
+ params(
161
+ req: T::Hash[Symbol, T.untyped],
162
+ bump_lower_bound: T::Boolean
163
+ ).returns(T::Hash[Symbol, T.untyped])
164
+ end
165
+ def update_pyproject_version_core(req, bump_lower_bound:)
142
166
  requirement_strings = req[:requirement].split(",").map(&:strip)
143
167
 
144
168
  new_requirement =
145
169
  if requirement_strings.any? { |r| r.match?(/^=|^\d/) }
146
- # If there is an equality operator, just update that. It must
147
- # be binding and any other requirements will be being ignored
148
170
  find_and_update_equality_match(requirement_strings)
149
171
  elsif requirement_strings.any? { |r| r.start_with?("~", "^") }
150
- # If a compatibility operator is being used, just bump its
151
- # version (and remove any other requirements)
152
172
  v_req = requirement_strings.find { |r| r.start_with?("~", "^") }
153
173
  bump_version(v_req, latest_resolvable_version.to_s)
154
- elsif new_version_satisfies?(req)
155
- # Otherwise we're looking at a range operator. No change
156
- # required if it's already satisfied
157
- req.fetch(:requirement)
174
+ elsif bump_lower_bound
175
+ bump_requirements_range(requirement_strings)
158
176
  else
159
- # But if it's not, update it
160
177
  update_requirements_range(requirement_strings)
161
178
  end
162
179
 
@@ -344,6 +361,59 @@ module Dependabot
344
361
  .sort_by { |r| requirement_class.new(r).requirements.first.last }.join(",").delete(" ")
345
362
  end
346
363
 
364
+ # Bumps the lower bound of a range requirement to the latest version
365
+ # Used by BumpVersions strategy to increase the minimum version
366
+ sig { params(requirement_strings: T::Array[String]).returns(String) }
367
+ def bump_requirements_range(requirement_strings)
368
+ ruby_requirements = requirement_strings.map { |r| requirement_class.new(r) }
369
+
370
+ validate_lower_bounds_not_too_high(ruby_requirements)
371
+
372
+ updated_requirement_strings = ruby_requirements.map { |r| bump_single_requirement(r) }
373
+
374
+ updated_requirement_strings
375
+ .sort_by { |r| requirement_class.new(r).requirements.first.last }.join(",").delete(" ")
376
+ end
377
+
378
+ sig { params(ruby_requirements: T::Array[Dependabot::Python::Requirement]).void }
379
+ def validate_lower_bounds_not_too_high(ruby_requirements)
380
+ ruby_requirements.each do |r|
381
+ op, version = r.requirements.first
382
+ raise UnfixableRequirement if LOWER_BOUND_OPS.include?(op) && version > T.must(latest_resolvable_version)
383
+ end
384
+ end
385
+
386
+ sig { params(req: Dependabot::Python::Requirement).returns(String) }
387
+ def bump_single_requirement(req)
388
+ op, version = req.requirements.first
389
+
390
+ case op
391
+ when ">=" then ">=" + T.must(latest_resolvable_version).to_s
392
+ # Strict lower bound becomes inclusive because the resolved version
393
+ # is the exact target — using ">" would exclude it.
394
+ when ">" then ">=" + T.must(latest_resolvable_version).to_s
395
+ when "<" then bump_upper_bound_less_than(req, version)
396
+ when "<=" then bump_upper_bound_less_or_equal(req)
397
+ when "~>", "~=" then bump_version(req.to_s, T.must(latest_resolvable_version).to_s)
398
+ when "!=" then req.to_s
399
+ else req.to_s
400
+ end
401
+ end
402
+
403
+ sig { params(req: Dependabot::Python::Requirement, version: Gem::Version).returns(String) }
404
+ def bump_upper_bound_less_than(req, version)
405
+ return req.to_s if req.satisfied_by?(T.must(latest_resolvable_version))
406
+
407
+ "<" + update_greatest_version(version, T.must(latest_resolvable_version))
408
+ end
409
+
410
+ sig { params(req: Dependabot::Python::Requirement).returns(String) }
411
+ def bump_upper_bound_less_or_equal(req)
412
+ return req.to_s if req.satisfied_by?(T.must(latest_resolvable_version))
413
+
414
+ "<=" + T.must(latest_resolvable_version).to_s
415
+ end
416
+
347
417
  # Updates the version in a constraint to be the given version
348
418
  sig { params(req_string: String, version_to_be_permitted: String).returns(String) }
349
419
  def bump_version(req_string, version_to_be_permitted)
@@ -448,6 +518,7 @@ module Dependabot
448
518
  Python::Requirement
449
519
  end
450
520
  end
521
+ # rubocop:enable Metrics/ClassLength
451
522
  end
452
523
  end
453
524
  end
@@ -409,14 +409,18 @@ module Dependabot
409
409
  def check_pypi_for_library_match
410
410
  return false unless updating_pyproject? && library_details && !T.must(library_details)["name"].nil?
411
411
 
412
+ # If the project has a description in its pyproject.toml metadata, treat it as a
413
+ # library when PyPI is unavailable or the package isn't published there yet.
414
+ has_library_metadata = !T.must(library_details)["description"].nil?
415
+
412
416
  response = Dependabot::RegistryClient.get(
413
417
  url: "https://pypi.org/pypi/#{normalised_name(T.must(library_details)['name'])}/json/"
414
418
  )
415
- return false unless response.status == 200
419
+ return has_library_metadata unless response.status == 200
416
420
 
417
421
  (JSON.parse(response.body)["info"] || {})["summary"] == T.must(library_details)["description"]
418
422
  rescue Excon::Error::Timeout, Excon::Error::Socket, URI::InvalidURIError
419
- false
423
+ has_library_metadata
420
424
  end
421
425
 
422
426
  sig { returns(T::Boolean) }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-python
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.369.0
4
+ version: 0.370.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.369.0
18
+ version: 0.370.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.369.0
25
+ version: 0.370.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -256,6 +256,7 @@ files:
256
256
  - helpers/test/fixtures/pep621_multiple_extras.toml
257
257
  - helpers/test/fixtures/pep621_no_version.toml
258
258
  - helpers/test/fixtures/pep621_only_build_system.toml
259
+ - helpers/test/fixtures/pep621_spaced_specifiers.toml
259
260
  - helpers/test/fixtures/pep735_cycle.toml
260
261
  - helpers/test/fixtures/pep735_dependency_groups.toml
261
262
  - helpers/test/fixtures/requirements/constraints.txt
@@ -288,6 +289,7 @@ files:
288
289
  - lib/dependabot/python/file_updater/pipfile_manifest_updater.rb
289
290
  - lib/dependabot/python/file_updater/pipfile_preparer.rb
290
291
  - lib/dependabot/python/file_updater/poetry_file_updater.rb
292
+ - lib/dependabot/python/file_updater/poetry_file_updater/pep621_updater.rb
291
293
  - lib/dependabot/python/file_updater/pyproject_preparer.rb
292
294
  - lib/dependabot/python/file_updater/requirement_file_updater.rb
293
295
  - lib/dependabot/python/file_updater/requirement_replacer.rb
@@ -302,6 +304,7 @@ files:
302
304
  - lib/dependabot/python/package_manager.rb
303
305
  - lib/dependabot/python/pip_compile_file_matcher.rb
304
306
  - lib/dependabot/python/pipenv_runner.rb
307
+ - lib/dependabot/python/poetry_plugin_installer.rb
305
308
  - lib/dependabot/python/requirement.rb
306
309
  - lib/dependabot/python/requirement_parser.rb
307
310
  - lib/dependabot/python/shared_file_fetcher.rb
@@ -319,7 +322,7 @@ licenses:
319
322
  - MIT
320
323
  metadata:
321
324
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
322
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.369.0
325
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.370.0
323
326
  rdoc_options: []
324
327
  require_paths:
325
328
  - lib