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 +4 -4
- data/helpers/lib/parser.py +26 -0
- data/helpers/test/fixtures/pep621_spaced_specifiers.toml +10 -0
- data/helpers/test/test_parser.py +81 -0
- data/lib/dependabot/python/dependency_grapher/lockfile_generator.rb +13 -0
- data/lib/dependabot/python/file_parser/pyproject_files_parser.rb +46 -12
- data/lib/dependabot/python/file_parser.rb +21 -1
- data/lib/dependabot/python/file_updater/poetry_file_updater/pep621_updater.rb +162 -0
- data/lib/dependabot/python/file_updater/poetry_file_updater.rb +48 -70
- data/lib/dependabot/python/file_updater/pyproject_preparer.rb +58 -52
- data/lib/dependabot/python/package_manager.rb +29 -11
- data/lib/dependabot/python/poetry_plugin_installer.rb +95 -0
- data/lib/dependabot/python/shared_file_fetcher.rb +33 -2
- data/lib/dependabot/python/update_checker/poetry_version_resolver.rb +13 -0
- data/lib/dependabot/python/update_checker/requirements_updater.rb +111 -28
- data/lib/dependabot/python/update_checker.rb +13 -5
- metadata +7 -4
|
@@ -16,29 +16,31 @@ module Dependabot
|
|
|
16
16
|
class PyprojectPreparer
|
|
17
17
|
extend T::Sig
|
|
18
18
|
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Regexp
|
|
24
|
-
|
|
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,
|
|
30
|
-
def self.pin_pep508_entry(entry,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
84
|
+
normalised_name = NameNormaliser.normalise(dep.name)
|
|
83
85
|
dep_arrays.each do |arr|
|
|
84
86
|
arr.each_with_index do |entry, i|
|
|
85
|
-
|
|
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,
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
POETRY_V1 = "1"
|
|
61
|
+
POETRY_V2 = "2"
|
|
61
62
|
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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|
|
|
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
|
|
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
|
-
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
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?
|
|
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(
|
|
418
|
+
url: "https://pypi.org/pypi/#{normalised_name(library_details_temp['name'])}/json/"
|
|
414
419
|
)
|
|
415
|
-
return
|
|
420
|
+
return has_library_metadata unless response.status == 200
|
|
416
421
|
|
|
417
|
-
|
|
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
|
-
|
|
427
|
+
has_library_metadata
|
|
420
428
|
end
|
|
421
429
|
|
|
422
430
|
sig { returns(T::Boolean) }
|