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.
@@ -16,29 +16,31 @@ 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
+ # Skip direct references (e.g. "pkg @ git+https://...") — already pinned to a URL
37
+ return entry if rest.match?(/\A\s*@\s/)
38
+
39
+ # Extract the environment marker ("; ..." suffix) if present
40
+ marker_match = rest.match(/(\s*;.*)/)
41
+ marker = marker_match ? marker_match[1] : ""
42
+
43
+ "#{prefix}==#{version}#{marker}"
42
44
  end
43
45
  private_class_method :pin_pep508_entry
44
46
 
@@ -79,12 +81,14 @@ module Dependabot
79
81
 
80
82
  sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
81
83
  def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
82
- name_pattern = pep508_name_pattern(dep.name)
84
+ normalised_name = NameNormaliser.normalise(dep.name)
83
85
  dep_arrays.each do |arr|
84
86
  arr.each_with_index do |entry, i|
85
- next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*(\z|[><=!~;,])/i)
87
+ match = entry.match(PEP508_PREFIX)
88
+ next unless match
89
+ next unless NameNormaliser.normalise(T.must(match[:name])) == normalised_name
86
90
 
87
- arr[i] = pin_pep508_entry(entry, name_pattern, T.must(dep.version))
91
+ arr[i] = pin_pep508_entry(entry, T.must(dep.version))
88
92
  end
89
93
  end
90
94
  end
@@ -138,8 +142,8 @@ module Dependabot
138
142
  .gsub('#{', "{")
139
143
  end
140
144
 
141
- # rubocop:disable Metrics/PerceivedComplexity
142
- # rubocop:disable Metrics/AbcSize
145
+ UNSUPPORTED_SOURCE_TYPES = T.let(%w(directory file url).freeze, T::Array[String])
146
+
143
147
  sig { params(dependencies: T::Array[Dependabot::Dependency]).returns(String) }
144
148
  def freeze_top_level_dependencies_except(dependencies)
145
149
  return pyproject_content unless lockfile
@@ -154,32 +158,10 @@ module Dependabot
154
158
  Dependabot::Python::FileParser::PyprojectFilesParser::POETRY_DEPENDENCY_TYPES.each do |key|
155
159
  next unless poetry_object[key]
156
160
 
157
- source_types = %w(directory file url)
158
161
  poetry_object.fetch(key).each do |dep_name, _|
159
162
  next if excluded_names.include?(normalise(dep_name))
160
163
 
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
164
+ freeze_poetry_dep!(poetry_object[key], dep_name)
183
165
  end
184
166
  end
185
167
 
@@ -188,8 +170,6 @@ module Dependabot
188
170
 
189
171
  TomlRB.dump(pyproject_object)
190
172
  end
191
- # rubocop:enable Metrics/AbcSize
192
- # rubocop:enable Metrics/PerceivedComplexity
193
173
 
194
174
  private
195
175
 
@@ -199,6 +179,33 @@ module Dependabot
199
179
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
200
180
  attr_reader :lockfile
201
181
 
182
+ sig { params(deps_hash: T::Hash[String, T.untyped], dep_name: String).void }
183
+ def freeze_poetry_dep!(deps_hash, dep_name)
184
+ details = locked_details(dep_name)
185
+ return unless (locked_version = details&.fetch("version"))
186
+
187
+ source_type = details.dig("source", "type")
188
+ return if UNSUPPORTED_SOURCE_TYPES.include?(source_type)
189
+
190
+ if source_type == "git"
191
+ freeze_git_dep!(deps_hash, dep_name, details)
192
+ elsif deps_hash[dep_name].is_a?(Hash)
193
+ deps_hash[dep_name]["version"] = locked_version
194
+ elsif !deps_hash[dep_name].is_a?(Array)
195
+ deps_hash[dep_name] = locked_version
196
+ end
197
+ end
198
+
199
+ sig { params(deps_hash: T::Hash[String, T.untyped], dep_name: String, details: T::Hash[String, T.untyped]).void }
200
+ def freeze_git_dep!(deps_hash, dep_name, details)
201
+ deps_hash[dep_name] = {
202
+ "git" => details.dig("source", "url"),
203
+ "rev" => details.dig("source", "reference")
204
+ }
205
+ subdirectory = details.dig("source", "subdirectory")
206
+ deps_hash[dep_name]["subdirectory"] = subdirectory if subdirectory
207
+ end
208
+
202
209
  sig { params(pyproject_object: T::Hash[String, T.untyped], excluded_names: T::Array[String]).void }
203
210
  def freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
204
211
  project_object = pyproject_object["project"]
@@ -226,8 +233,7 @@ module Dependabot
226
233
  locked_details = locked_details(dep_name)
227
234
  next unless (locked_version = locked_details&.fetch("version"))
228
235
 
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)
236
+ dep_array[index] = self.class.send(:pin_pep508_entry, entry, locked_version)
231
237
  end
232
238
  end
233
239
 
@@ -57,9 +57,19 @@ module Dependabot
57
57
 
58
58
  LOCKFILE_NAME = "poetry.lock"
59
59
 
60
- SUPPORTED_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version])
60
+ POETRY_V1 = "1"
61
+ POETRY_V2 = "2"
61
62
 
62
- DEPRECATED_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version])
63
+ # Keep versions in ascending order
64
+ SUPPORTED_VERSIONS = T.let(
65
+ [
66
+ Version.new(POETRY_V1),
67
+ Version.new(POETRY_V2)
68
+ ].freeze,
69
+ T::Array[Dependabot::Version]
70
+ )
71
+
72
+ DEPRECATED_VERSIONS = T.let([Version.new(POETRY_V1)].freeze, T::Array[Dependabot::Version])
63
73
 
64
74
  sig do
65
75
  params(
@@ -68,23 +78,31 @@ module Dependabot
68
78
  ).void
69
79
  end
70
80
  def initialize(raw_version, requirement = nil)
81
+ version = Version.new(raw_version)
71
82
  super(
72
83
  name: NAME,
73
- version: Version.new(raw_version),
84
+ detected_version: Version.new(T.must(version.segments.first).to_s),
85
+ version: version,
74
86
  deprecated_versions: DEPRECATED_VERSIONS,
75
87
  supported_versions: SUPPORTED_VERSIONS,
76
88
  requirement: requirement,
77
89
  )
78
90
  end
79
91
 
80
- sig { override.returns(T::Boolean) }
81
- def deprecated?
82
- false
83
- end
84
-
85
- sig { override.returns(T::Boolean) }
86
- def unsupported?
87
- false
92
+ # Poetry supports requires-poetry constraints in pyproject.toml;
93
+ # other Python package managers don't have an equivalent mechanism.
94
+ sig { override.void }
95
+ def raise_if_unsupported!
96
+ super
97
+ return unless requirement
98
+ return unless version
99
+ return if T.cast(T.must(requirement).satisfied_by?(T.must(version)), T::Boolean)
100
+
101
+ raise Dependabot::ToolVersionNotSupported.new(
102
+ NAME,
103
+ version.to_s,
104
+ requirement.to_s
105
+ )
88
106
  end
89
107
  end
90
108
 
@@ -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
@@ -26,6 +26,19 @@ module Dependabot
26
26
  DEPENDENCY_TYPES = T.let(%w(packages dev-packages).freeze, T::Array[String])
27
27
  MAX_FILE_SIZE = T.let(500_000, Integer)
28
28
 
29
+ # Regex patterns for detecting Python requirements.txt manifest variants.
30
+ # Ported from github/dependency-snapshots-api.
31
+ #
32
+ # Matches "requirements" preceded by a hyphen, period, underscore, start-of-string, or slash,
33
+ # followed by non-whitespace chars and ".txt".
34
+ # Examples: requirements.txt, requirements.prod.txt, requirements/production.txt
35
+ REQUIREMENTS_TXT_REGEX = T.let(%r{(?:[-._]|^|/)requirements[^\s]*\.txt$}i, Regexp)
36
+
37
+ # More lenient: matches "require" with optional prefix (no dots/whitespace)
38
+ # and optional hyphen/underscore/slash suffix. Does not match "require" as a substring.
39
+ # Examples: require.txt, require-test.txt, py3-require.txt, pyenv_require_e2e.txt
40
+ REQUIRE_TXT_REGEX = T.let(%r{[^\s|.]*require(?:[-_/][^\s|.]*)?\.txt$}i, Regexp)
41
+
29
42
  sig { abstract.returns(T::Array[String]) }
30
43
  def self.ecosystem_specific_required_files; end
31
44
 
@@ -169,7 +182,7 @@ module Dependabot
169
182
 
170
183
  repo_contents
171
184
  .select { |f| f.type == "file" }
172
- .select { |f| f.name.end_with?(".txt", ".in") }
185
+ .select { |f| potential_requirements_file?(f.name) }
173
186
  .reject { |f| f.size > MAX_FILE_SIZE }
174
187
  .map { |f| fetch_file_from_host(f.name) }
175
188
  .select { |f| requirements_file?(f) }
@@ -193,7 +206,7 @@ module Dependabot
193
206
 
194
207
  repo_contents(dir: relative_reqs_dir)
195
208
  .select { |f| f.type == "file" }
196
- .select { |f| f.name.end_with?(".txt", ".in") }
209
+ .select { |f| potential_requirements_file?(File.join(relative_reqs_dir, f.name)) }
197
210
  .reject { |f| f.size > MAX_FILE_SIZE }
198
211
  .map { |f| fetch_file_from_host("#{relative_reqs_dir}/#{f.name}") }
199
212
  .select { |f| requirements_file?(f) }
@@ -379,6 +392,24 @@ module Dependabot
379
392
  uneditable_reqs + editable_reqs
380
393
  end
381
394
 
395
+ # Checks if a filename matches known Python requirements.txt naming patterns.
396
+ sig { params(path: String).returns(T::Boolean) }
397
+ def requirements_txt_filename?(path)
398
+ path.match?(REQUIREMENTS_TXT_REGEX) || path.match?(REQUIRE_TXT_REGEX)
399
+ end
400
+
401
+ # When the feature flag is enabled, only considers .txt files whose names match
402
+ # requirements patterns (plus all .in files). When disabled, falls back to the
403
+ # original behavior of accepting any .txt or .in file.
404
+ sig { params(path: String).returns(T::Boolean) }
405
+ def potential_requirements_file?(path)
406
+ unless Dependabot::Experiments.enabled?(:python_requirements_file_name_filtering)
407
+ return path.end_with?(".txt", ".in")
408
+ end
409
+
410
+ path.end_with?(".in") || requirements_txt_filename?(path)
411
+ end
412
+
382
413
  sig { params(path: String).returns(String) }
383
414
  def clean_path(path)
384
415
  Pathname.new(path).cleanpath.to_path
@@ -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
 
@@ -250,24 +267,36 @@ module Dependabot
250
267
 
251
268
  sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
252
269
  def update_requirement(req)
253
- requirement_strings = req[:requirement].split(",").map(&:strip)
254
-
255
- new_requirement =
256
- if requirement_strings.any? { |r| r.match?(/^[=\d]/) }
257
- find_and_update_equality_match(requirement_strings)
258
- elsif requirement_strings.any? { |r| r.start_with?("~=") }
259
- tw_req = requirement_strings.find { |r| r.start_with?("~=") }
260
- bump_version(tw_req, latest_resolvable_version.to_s)
261
- elsif new_version_satisfies?(req)
262
- req.fetch(:requirement)
263
- else
264
- update_requirements_range(requirement_strings)
265
- end
270
+ new_requirement = updated_requirement_string(req)
266
271
  req.merge(requirement: new_requirement)
267
272
  rescue UnfixableRequirement
268
273
  req.merge(requirement: :unfixable)
269
274
  end
270
275
 
276
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T.any(String, Symbol)) }
277
+ def updated_requirement_string(req)
278
+ requirement_strings = req[:requirement].split(",").map(&:strip)
279
+
280
+ if requirement_strings.any? { |r| r.match?(/^[=\d]/) }
281
+ find_and_update_equality_match(requirement_strings)
282
+ elsif requirement_strings.any? { |r| r.start_with?("~=") }
283
+ tw_req = requirement_strings.find { |r| r.start_with?("~=") }
284
+ bump_version(tw_req, latest_resolvable_version.to_s)
285
+ elsif bump_lower_bound?(requirement_strings)
286
+ bump_requirements_range(requirement_strings)
287
+ elsif new_version_satisfies?(req)
288
+ req.fetch(:requirement)
289
+ else
290
+ update_requirements_range(requirement_strings)
291
+ end
292
+ end
293
+
294
+ sig { params(requirement_strings: T::Array[String]).returns(T::Boolean) }
295
+ def bump_lower_bound?(requirement_strings)
296
+ update_strategy == RequirementsUpdateStrategy::BumpVersions &&
297
+ requirement_strings.any? { |r| r.strip.start_with?(">") }
298
+ end
299
+
271
300
  sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
272
301
  def widen_requirement(req)
273
302
  return req if new_version_satisfies?(req)
@@ -344,6 +373,59 @@ module Dependabot
344
373
  .sort_by { |r| requirement_class.new(r).requirements.first.last }.join(",").delete(" ")
345
374
  end
346
375
 
376
+ # Bumps the lower bound of a range requirement to the latest version
377
+ # Used by BumpVersions strategy to increase the minimum version
378
+ sig { params(requirement_strings: T::Array[String]).returns(String) }
379
+ def bump_requirements_range(requirement_strings)
380
+ ruby_requirements = requirement_strings.map { |r| requirement_class.new(r) }
381
+
382
+ validate_lower_bounds_not_too_high(ruby_requirements)
383
+
384
+ updated_requirement_strings = ruby_requirements.map { |r| bump_single_requirement(r) }
385
+
386
+ updated_requirement_strings
387
+ .sort_by { |r| requirement_class.new(r).requirements.first.last }.join(",").delete(" ")
388
+ end
389
+
390
+ sig { params(ruby_requirements: T::Array[Dependabot::Python::Requirement]).void }
391
+ def validate_lower_bounds_not_too_high(ruby_requirements)
392
+ ruby_requirements.each do |r|
393
+ op, version = r.requirements.first
394
+ raise UnfixableRequirement if LOWER_BOUND_OPS.include?(op) && version > T.must(latest_resolvable_version)
395
+ end
396
+ end
397
+
398
+ sig { params(req: Dependabot::Python::Requirement).returns(String) }
399
+ def bump_single_requirement(req)
400
+ op, version = req.requirements.first
401
+
402
+ case op
403
+ when ">=" then ">=" + T.must(latest_resolvable_version).to_s
404
+ # Strict lower bound becomes inclusive because the resolved version
405
+ # is the exact target — using ">" would exclude it.
406
+ when ">" then ">=" + T.must(latest_resolvable_version).to_s
407
+ when "<" then bump_upper_bound_less_than(req, version)
408
+ when "<=" then bump_upper_bound_less_or_equal(req)
409
+ when "~>", "~=" then bump_version(req.to_s, T.must(latest_resolvable_version).to_s)
410
+ when "!=" then req.to_s
411
+ else req.to_s
412
+ end
413
+ end
414
+
415
+ sig { params(req: Dependabot::Python::Requirement, version: Gem::Version).returns(String) }
416
+ def bump_upper_bound_less_than(req, version)
417
+ return req.to_s if req.satisfied_by?(T.must(latest_resolvable_version))
418
+
419
+ "<" + update_greatest_version(version, T.must(latest_resolvable_version))
420
+ end
421
+
422
+ sig { params(req: Dependabot::Python::Requirement).returns(String) }
423
+ def bump_upper_bound_less_or_equal(req)
424
+ return req.to_s if req.satisfied_by?(T.must(latest_resolvable_version))
425
+
426
+ "<=" + T.must(latest_resolvable_version).to_s
427
+ end
428
+
347
429
  # Updates the version in a constraint to be the given version
348
430
  sig { params(req_string: String, version_to_be_permitted: String).returns(String) }
349
431
  def bump_version(req_string, version_to_be_permitted)
@@ -448,6 +530,7 @@ module Dependabot
448
530
  Python::Requirement
449
531
  end
450
532
  end
533
+ # rubocop:enable Metrics/ClassLength
451
534
  end
452
535
  end
453
536
  end
@@ -407,16 +407,24 @@ module Dependabot
407
407
 
408
408
  sig { returns(T::Boolean) }
409
409
  def check_pypi_for_library_match
410
- return false unless updating_pyproject? && library_details && !T.must(library_details)["name"].nil?
410
+ return false unless updating_pyproject?
411
+
412
+ library_details_temp = library_details
413
+ return false unless library_details_temp && !library_details_temp["name"].nil?
414
+
415
+ has_library_metadata = !library_details_temp["description"].nil?
411
416
 
412
417
  response = Dependabot::RegistryClient.get(
413
- url: "https://pypi.org/pypi/#{normalised_name(T.must(library_details)['name'])}/json/"
418
+ url: "https://pypi.org/pypi/#{normalised_name(library_details_temp['name'])}/json/"
414
419
  )
415
- return false unless response.status == 200
420
+ return has_library_metadata unless response.status == 200
416
421
 
417
- (JSON.parse(response.body)["info"] || {})["summary"] == T.must(library_details)["description"]
422
+ local_description = library_details_temp["description"]
423
+ return true if local_description.nil?
424
+
425
+ (JSON.parse(response.body)["info"] || {})["summary"] == local_description
418
426
  rescue Excon::Error::Timeout, Excon::Error::Socket, URI::InvalidURIError
419
- false
427
+ has_library_metadata
420
428
  end
421
429
 
422
430
  sig { returns(T::Boolean) }