dependabot-python 0.79.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 +7 -0
- data/helpers/build +17 -0
- data/helpers/lib/__init__.py +0 -0
- data/helpers/lib/hasher.py +23 -0
- data/helpers/lib/parser.py +130 -0
- data/helpers/requirements.txt +9 -0
- data/helpers/run.py +18 -0
- data/lib/dependabot/python.rb +11 -0
- data/lib/dependabot/python/file_fetcher.rb +307 -0
- data/lib/dependabot/python/file_parser.rb +221 -0
- data/lib/dependabot/python/file_parser/pipfile_files_parser.rb +150 -0
- data/lib/dependabot/python/file_parser/poetry_files_parser.rb +139 -0
- data/lib/dependabot/python/file_parser/setup_file_parser.rb +158 -0
- data/lib/dependabot/python/file_updater.rb +149 -0
- data/lib/dependabot/python/file_updater/pip_compile_file_updater.rb +361 -0
- data/lib/dependabot/python/file_updater/pipfile_file_updater.rb +391 -0
- data/lib/dependabot/python/file_updater/pipfile_preparer.rb +123 -0
- data/lib/dependabot/python/file_updater/poetry_file_updater.rb +282 -0
- data/lib/dependabot/python/file_updater/pyproject_preparer.rb +103 -0
- data/lib/dependabot/python/file_updater/requirement_file_updater.rb +160 -0
- data/lib/dependabot/python/file_updater/requirement_replacer.rb +93 -0
- data/lib/dependabot/python/file_updater/setup_file_sanitizer.rb +89 -0
- data/lib/dependabot/python/metadata_finder.rb +122 -0
- data/lib/dependabot/python/native_helpers.rb +17 -0
- data/lib/dependabot/python/python_versions.rb +25 -0
- data/lib/dependabot/python/requirement.rb +129 -0
- data/lib/dependabot/python/requirement_parser.rb +38 -0
- data/lib/dependabot/python/update_checker.rb +229 -0
- data/lib/dependabot/python/update_checker/latest_version_finder.rb +250 -0
- data/lib/dependabot/python/update_checker/pip_compile_version_resolver.rb +379 -0
- data/lib/dependabot/python/update_checker/pipfile_version_resolver.rb +558 -0
- data/lib/dependabot/python/update_checker/poetry_version_resolver.rb +298 -0
- data/lib/dependabot/python/update_checker/requirements_updater.rb +365 -0
- data/lib/dependabot/python/version.rb +87 -0
- metadata +203 -0
@@ -0,0 +1,379 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dependabot/python/requirement_parser"
|
4
|
+
require "dependabot/python/file_fetcher"
|
5
|
+
require "dependabot/python/file_parser"
|
6
|
+
require "dependabot/python/update_checker"
|
7
|
+
require "dependabot/python/file_updater/requirement_replacer"
|
8
|
+
require "dependabot/python/file_updater/setup_file_sanitizer"
|
9
|
+
require "dependabot/python/version"
|
10
|
+
require "dependabot/shared_helpers"
|
11
|
+
require "dependabot/python/native_helpers"
|
12
|
+
|
13
|
+
# rubocop:disable Metrics/ClassLength
|
14
|
+
module Dependabot
|
15
|
+
module Python
|
16
|
+
class UpdateChecker
|
17
|
+
# This class does version resolution for pip-compile. Its approach is:
|
18
|
+
# - Unlock the dependency we're checking in the requirements.in file
|
19
|
+
# - Run `pip-compile` and see what the result is
|
20
|
+
class PipCompileVersionResolver
|
21
|
+
VERSION_REGEX = /[0-9]+(?:\.[A-Za-z0-9\-_]+)*/.freeze
|
22
|
+
|
23
|
+
attr_reader :dependency, :dependency_files, :credentials
|
24
|
+
|
25
|
+
def initialize(dependency:, dependency_files:, credentials:,
|
26
|
+
unlock_requirement:, latest_allowable_version:)
|
27
|
+
@dependency = dependency
|
28
|
+
@dependency_files = dependency_files
|
29
|
+
@credentials = credentials
|
30
|
+
@latest_allowable_version = latest_allowable_version
|
31
|
+
@unlock_requirement = unlock_requirement
|
32
|
+
end
|
33
|
+
|
34
|
+
def latest_resolvable_version
|
35
|
+
return @latest_resolvable_version if @resolution_already_attempted
|
36
|
+
|
37
|
+
@resolution_already_attempted = true
|
38
|
+
@latest_resolvable_version ||= fetch_latest_resolvable_version
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
attr_reader :latest_allowable_version
|
44
|
+
|
45
|
+
def unlock_requirement?
|
46
|
+
@unlock_requirement
|
47
|
+
end
|
48
|
+
|
49
|
+
def fetch_latest_resolvable_version
|
50
|
+
@latest_resolvable_version_string ||=
|
51
|
+
SharedHelpers.in_a_temporary_directory do
|
52
|
+
SharedHelpers.with_git_configured(credentials: credentials) do
|
53
|
+
write_temporary_dependency_files
|
54
|
+
|
55
|
+
filenames_to_compile.each do |filename|
|
56
|
+
# Shell out to pip-compile.
|
57
|
+
# This is slow, as pip-compile needs to do installs.
|
58
|
+
cmd = "pyenv exec pip-compile --allow-unsafe "\
|
59
|
+
"-P #{dependency.name} #{filename}"
|
60
|
+
run_command(cmd)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Remove any .python-version file before parsing the reqs
|
64
|
+
FileUtils.remove_entry(".python-version", true)
|
65
|
+
|
66
|
+
parse_updated_files
|
67
|
+
end
|
68
|
+
rescue SharedHelpers::HelperSubprocessFailed => error
|
69
|
+
handle_pip_compile_errors(error)
|
70
|
+
end
|
71
|
+
return unless @latest_resolvable_version_string
|
72
|
+
|
73
|
+
Python::Version.new(@latest_resolvable_version_string)
|
74
|
+
end
|
75
|
+
|
76
|
+
def parse_requirements_from_cwd_files
|
77
|
+
SharedHelpers.run_helper_subprocess(
|
78
|
+
command: "pyenv exec python #{NativeHelpers.python_helper_path}",
|
79
|
+
function: "parse_requirements",
|
80
|
+
args: [Dir.pwd]
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
def handle_pip_compile_errors(error)
|
85
|
+
if error.message.include?("Could not find a version")
|
86
|
+
check_original_requirements_resolvable
|
87
|
+
# If the original requirements are resolvable but we get an
|
88
|
+
# incompatibility update after unlocking then it's likely to be
|
89
|
+
# due to problems with pip-compile's cascading resolution
|
90
|
+
return nil
|
91
|
+
end
|
92
|
+
|
93
|
+
if error.message.include?('Command "python setup.py egg_info') &&
|
94
|
+
error.message.include?(dependency.name)
|
95
|
+
# The latest version of the dependency we're updating is borked
|
96
|
+
# (because it has an unevaluatable setup.py). Skip the update.
|
97
|
+
return nil
|
98
|
+
end
|
99
|
+
|
100
|
+
if error.message.include?("Could not find a version ") &&
|
101
|
+
!error.message.include?(dependency.name)
|
102
|
+
# Sometimes pip-tools gets confused and can't work around
|
103
|
+
# sub-dependency incompatibilities. Ignore those cases.
|
104
|
+
return nil
|
105
|
+
end
|
106
|
+
|
107
|
+
raise
|
108
|
+
end
|
109
|
+
|
110
|
+
# Needed because pip-compile's resolver isn't perfect.
|
111
|
+
# Note: We raise errors from this method, rather than returning a
|
112
|
+
# boolean, so that all deps for this repo will raise identical
|
113
|
+
# errors when failing to update
|
114
|
+
def check_original_requirements_resolvable
|
115
|
+
SharedHelpers.in_a_temporary_directory do
|
116
|
+
SharedHelpers.with_git_configured(credentials: credentials) do
|
117
|
+
write_temporary_dependency_files(unlock_requirement: false)
|
118
|
+
|
119
|
+
filenames_to_compile.each do |filename|
|
120
|
+
cmd = "pyenv exec pip-compile --allow-unsafe #{filename}"
|
121
|
+
run_command(cmd)
|
122
|
+
end
|
123
|
+
|
124
|
+
true
|
125
|
+
rescue SharedHelpers::HelperSubprocessFailed => error
|
126
|
+
raise unless error.message.include?("Could not find a version")
|
127
|
+
|
128
|
+
msg = clean_error_message(error.message)
|
129
|
+
raise if msg.empty?
|
130
|
+
|
131
|
+
raise DependencyFileNotResolvable, msg
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def run_command(command)
|
137
|
+
command = command.dup
|
138
|
+
raw_response = nil
|
139
|
+
IO.popen(command, err: %i(child out)) do |process|
|
140
|
+
raw_response = process.read
|
141
|
+
end
|
142
|
+
|
143
|
+
# Raise an error with the output from the shell session if
|
144
|
+
# pip-compile returns a non-zero status
|
145
|
+
return if $CHILD_STATUS.success?
|
146
|
+
|
147
|
+
raise SharedHelpers::HelperSubprocessFailed.new(
|
148
|
+
raw_response,
|
149
|
+
command
|
150
|
+
)
|
151
|
+
rescue SharedHelpers::HelperSubprocessFailed => error
|
152
|
+
original_error ||= error
|
153
|
+
msg = error.message
|
154
|
+
|
155
|
+
relevant_error =
|
156
|
+
if error_suggests_bad_python_version?(msg) then original_error
|
157
|
+
else error
|
158
|
+
end
|
159
|
+
|
160
|
+
raise relevant_error unless error_suggests_bad_python_version?(msg)
|
161
|
+
raise relevant_error if File.exist?(".python-version")
|
162
|
+
|
163
|
+
command = "pyenv local 2.7.15 && " + command
|
164
|
+
retry
|
165
|
+
ensure
|
166
|
+
FileUtils.remove_entry(".python-version", true)
|
167
|
+
end
|
168
|
+
|
169
|
+
def error_suggests_bad_python_version?(message)
|
170
|
+
return true if message.include?("not find a version that satisfies")
|
171
|
+
|
172
|
+
message.include?('Command "python setup.py egg_info" failed')
|
173
|
+
end
|
174
|
+
|
175
|
+
def write_temporary_dependency_files(unlock_requirement: true)
|
176
|
+
dependency_files.each do |file|
|
177
|
+
next if file.name == ".python-version"
|
178
|
+
|
179
|
+
path = file.name
|
180
|
+
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
181
|
+
File.write(
|
182
|
+
path,
|
183
|
+
unlock_requirement ? unlock_dependency(file) : file.content
|
184
|
+
)
|
185
|
+
end
|
186
|
+
|
187
|
+
setup_files.each do |file|
|
188
|
+
path = file.name
|
189
|
+
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
190
|
+
File.write(path, sanitized_setup_file_content(file))
|
191
|
+
end
|
192
|
+
|
193
|
+
setup_cfg_files.each do |file|
|
194
|
+
path = file.name
|
195
|
+
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
196
|
+
File.write(path, "[metadata]\nname = sanitized-package\n")
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def sanitized_setup_file_content(file)
|
201
|
+
@sanitized_setup_file_content ||= {}
|
202
|
+
if @sanitized_setup_file_content[file.name]
|
203
|
+
return @sanitized_setup_file_content[file.name]
|
204
|
+
end
|
205
|
+
|
206
|
+
@sanitized_setup_file_content[file.name] =
|
207
|
+
Python::FileUpdater::SetupFileSanitizer.
|
208
|
+
new(setup_file: file, setup_cfg: setup_cfg(file)).
|
209
|
+
sanitized_content
|
210
|
+
end
|
211
|
+
|
212
|
+
def setup_cfg(file)
|
213
|
+
dependency_files.find do |f|
|
214
|
+
f.name == file.name.sub(/\.py$/, ".cfg")
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def unlock_dependency(file)
|
219
|
+
return file.content unless file.name.end_with?(".in")
|
220
|
+
return file.content unless dependency.version
|
221
|
+
return file.content unless unlock_requirement?
|
222
|
+
|
223
|
+
req = dependency.requirements.find { |r| r[:file] == file.name }
|
224
|
+
return file.content unless req&.fetch(:requirement)
|
225
|
+
|
226
|
+
Python::FileUpdater::RequirementReplacer.new(
|
227
|
+
content: file.content,
|
228
|
+
dependency_name: dependency.name,
|
229
|
+
old_requirement: req[:requirement],
|
230
|
+
new_requirement: updated_version_requirement_string
|
231
|
+
).updated_content
|
232
|
+
end
|
233
|
+
|
234
|
+
def updated_version_requirement_string
|
235
|
+
lower_bound_req = updated_version_req_lower_bound
|
236
|
+
|
237
|
+
# Add the latest_allowable_version as an upper bound. This means
|
238
|
+
# ignore conditions are considered when checking for the latest
|
239
|
+
# resolvable version.
|
240
|
+
#
|
241
|
+
# NOTE: This isn't perfect. If v2.x is ignored and v3 is out but
|
242
|
+
# unresolvable then the `latest_allowable_version` will be v3, and
|
243
|
+
# we won't be ignoring v2.x releases like we should be.
|
244
|
+
return lower_bound_req if latest_allowable_version.nil?
|
245
|
+
unless Python::Version.correct?(latest_allowable_version)
|
246
|
+
return lower_bound_req
|
247
|
+
end
|
248
|
+
|
249
|
+
lower_bound_req + ", <= #{latest_allowable_version}"
|
250
|
+
end
|
251
|
+
|
252
|
+
def updated_version_req_lower_bound
|
253
|
+
if dependency.version
|
254
|
+
">= #{dependency.version}"
|
255
|
+
else
|
256
|
+
version_for_requirement =
|
257
|
+
dependency.requirements.map { |r| r[:requirement] }.compact.
|
258
|
+
reject { |req_string| req_string.start_with?("<") }.
|
259
|
+
select { |req_string| req_string.match?(VERSION_REGEX) }.
|
260
|
+
map { |req_string| req_string.match(VERSION_REGEX) }.
|
261
|
+
select { |version| Gem::Version.correct?(version) }.
|
262
|
+
max_by { |version| Gem::Version.new(version) }
|
263
|
+
|
264
|
+
">= #{version_for_requirement || 0}"
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def python_helper_path
|
269
|
+
project_root = File.join(File.dirname(__FILE__), "../../../../..")
|
270
|
+
File.join(project_root, "helpers/python/run.py")
|
271
|
+
end
|
272
|
+
|
273
|
+
# See https://www.python.org/dev/peps/pep-0503/#normalized-names
|
274
|
+
def normalise(name)
|
275
|
+
name.downcase.gsub(/[-_.]+/, "-")
|
276
|
+
end
|
277
|
+
|
278
|
+
def clean_error_message(message)
|
279
|
+
# Redact any URLs, as they may include credentials
|
280
|
+
message.gsub(/http.*?(?=\s)/, "<redacted>")
|
281
|
+
end
|
282
|
+
|
283
|
+
def filenames_to_compile
|
284
|
+
files_from_reqs =
|
285
|
+
dependency.requirements.
|
286
|
+
map { |r| r[:file] }.
|
287
|
+
select { |fn| fn.end_with?(".in") }
|
288
|
+
|
289
|
+
files_from_compiled_files =
|
290
|
+
pip_compile_files.map(&:name).select do |fn|
|
291
|
+
compiled_file = dependency_files.
|
292
|
+
find { |f| f.name == fn.gsub(/\.in$/, ".txt") }
|
293
|
+
compiled_file_includes_dependency?(compiled_file)
|
294
|
+
end
|
295
|
+
|
296
|
+
filenames = [*files_from_reqs, *files_from_compiled_files].uniq
|
297
|
+
|
298
|
+
order_filenames_for_compilation(filenames)
|
299
|
+
end
|
300
|
+
|
301
|
+
def compiled_file_includes_dependency?(compiled_file)
|
302
|
+
return false unless compiled_file
|
303
|
+
|
304
|
+
regex = RequirementParser::INSTALL_REQ_WITH_REQUIREMENT
|
305
|
+
|
306
|
+
matches = []
|
307
|
+
compiled_file.content.scan(regex) { matches << Regexp.last_match }
|
308
|
+
matches.any? { |m| normalise(m[:name]) == dependency.name }
|
309
|
+
end
|
310
|
+
|
311
|
+
# If the files we need to update require one another then we need to
|
312
|
+
# update them in the right order
|
313
|
+
def order_filenames_for_compilation(filenames)
|
314
|
+
ordered_filenames = []
|
315
|
+
|
316
|
+
while (remaining_filenames = filenames - ordered_filenames).any?
|
317
|
+
ordered_filenames +=
|
318
|
+
remaining_filenames.
|
319
|
+
select do |fn|
|
320
|
+
unupdated_reqs = requirement_map[fn] - ordered_filenames
|
321
|
+
(unupdated_reqs & filenames).empty?
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
ordered_filenames
|
326
|
+
end
|
327
|
+
|
328
|
+
def requirement_map
|
329
|
+
child_req_regex = Python::FileFetcher::CHILD_REQUIREMENT_REGEX
|
330
|
+
@requirement_map ||=
|
331
|
+
pip_compile_files.each_with_object({}) do |file, req_map|
|
332
|
+
paths = file.content.scan(child_req_regex).flatten
|
333
|
+
current_dir = File.dirname(file.name)
|
334
|
+
|
335
|
+
req_map[file.name] =
|
336
|
+
paths.map do |path|
|
337
|
+
path = File.join(current_dir, path) if current_dir != "."
|
338
|
+
path = Pathname.new(path).cleanpath.to_path
|
339
|
+
path = path.gsub(/\.txt$/, ".in")
|
340
|
+
next if path == file.name
|
341
|
+
|
342
|
+
path
|
343
|
+
end.uniq.compact
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
def parse_updated_files
|
348
|
+
updated_files =
|
349
|
+
dependency_files.map do |file|
|
350
|
+
next file if file.name == ".python-version"
|
351
|
+
|
352
|
+
updated_file = file.dup
|
353
|
+
updated_file.content = File.read(file.name)
|
354
|
+
updated_file
|
355
|
+
end
|
356
|
+
|
357
|
+
Python::FileParser.new(
|
358
|
+
dependency_files: updated_files,
|
359
|
+
source: nil,
|
360
|
+
credentials: credentials
|
361
|
+
).parse.find { |d| d.name == dependency.name }&.version
|
362
|
+
end
|
363
|
+
|
364
|
+
def setup_files
|
365
|
+
dependency_files.select { |f| f.name.end_with?("setup.py") }
|
366
|
+
end
|
367
|
+
|
368
|
+
def pip_compile_files
|
369
|
+
dependency_files.select { |f| f.name.end_with?(".in") }
|
370
|
+
end
|
371
|
+
|
372
|
+
def setup_cfg_files
|
373
|
+
dependency_files.select { |f| f.name.end_with?("setup.cfg") }
|
374
|
+
end
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
378
|
+
end
|
379
|
+
# rubocop:enable Metrics/ClassLength
|
@@ -0,0 +1,558 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "excon"
|
4
|
+
require "toml-rb"
|
5
|
+
|
6
|
+
require "dependabot/python/file_parser"
|
7
|
+
require "dependabot/python/file_updater/pipfile_preparer"
|
8
|
+
require "dependabot/python/file_updater/setup_file_sanitizer"
|
9
|
+
require "dependabot/python/update_checker"
|
10
|
+
require "dependabot/python/python_versions"
|
11
|
+
require "dependabot/shared_helpers"
|
12
|
+
require "dependabot/python/version"
|
13
|
+
require "dependabot/errors"
|
14
|
+
|
15
|
+
# rubocop:disable Metrics/ClassLength
|
16
|
+
module Dependabot
|
17
|
+
module Python
|
18
|
+
class UpdateChecker
|
19
|
+
# This class does version resolution for Pipfiles. Its current approach
|
20
|
+
# is somewhat crude:
|
21
|
+
# - Unlock the dependency we're checking in the Pipfile
|
22
|
+
# - Freeze all of the other dependencies in the Pipfile
|
23
|
+
# - Run `pipenv lock` and see what the result is
|
24
|
+
#
|
25
|
+
# Unfortunately, Pipenv doesn't resolve how we'd expect - it appears to
|
26
|
+
# just raise if the latest version can't be resolved. Knowing that is
|
27
|
+
# still better than nothing, though.
|
28
|
+
class PipfileVersionResolver
|
29
|
+
VERSION_REGEX = /[0-9]+(?:\.[A-Za-z0-9\-_]+)*/.freeze
|
30
|
+
GIT_DEPENDENCY_UNREACHABLE_REGEX =
|
31
|
+
/Command "git clone -q (?<url>[^\s]+).*" failed/.freeze
|
32
|
+
GIT_REFERENCE_NOT_FOUND_REGEX =
|
33
|
+
%r{"git checkout -q (?<tag>[^"]+)" .*/(?<name>.*?)(\\n'\]|$)}.
|
34
|
+
freeze
|
35
|
+
UNSUPPORTED_DEPS = %w(pyobjc).freeze
|
36
|
+
UNSUPPORTED_DEP_REGEX =
|
37
|
+
/"python setup\.py egg_info".*(?:#{UNSUPPORTED_DEPS.join("|")})/.
|
38
|
+
freeze
|
39
|
+
|
40
|
+
attr_reader :dependency, :dependency_files, :credentials
|
41
|
+
|
42
|
+
def initialize(dependency:, dependency_files:, credentials:,
|
43
|
+
unlock_requirement:, latest_allowable_version:)
|
44
|
+
@dependency = dependency
|
45
|
+
@dependency_files = dependency_files
|
46
|
+
@credentials = credentials
|
47
|
+
@latest_allowable_version = latest_allowable_version
|
48
|
+
@unlock_requirement = unlock_requirement
|
49
|
+
|
50
|
+
check_private_sources_are_reachable
|
51
|
+
end
|
52
|
+
|
53
|
+
def latest_resolvable_version
|
54
|
+
return @latest_resolvable_version if @resolution_already_attempted
|
55
|
+
|
56
|
+
@resolution_already_attempted = true
|
57
|
+
@latest_resolvable_version ||= fetch_latest_resolvable_version
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
attr_reader :latest_allowable_version
|
63
|
+
|
64
|
+
def unlock_requirement?
|
65
|
+
@unlock_requirement
|
66
|
+
end
|
67
|
+
|
68
|
+
def fetch_latest_resolvable_version
|
69
|
+
@latest_resolvable_version_string ||=
|
70
|
+
SharedHelpers.in_a_temporary_directory do
|
71
|
+
SharedHelpers.with_git_configured(credentials: credentials) do
|
72
|
+
write_temporary_dependency_files
|
73
|
+
|
74
|
+
# Shell out to Pipenv, which handles everything for us.
|
75
|
+
# Whilst calling `lock` avoids doing an install as part of the
|
76
|
+
# pipenv flow, an install is still done by pip-tools in order
|
77
|
+
# to resolve the dependencies. That means this is slow.
|
78
|
+
run_pipenv_command(
|
79
|
+
pipenv_environment_variables + "pyenv exec pipenv lock"
|
80
|
+
)
|
81
|
+
|
82
|
+
updated_lockfile = JSON.parse(File.read("Pipfile.lock"))
|
83
|
+
|
84
|
+
fetch_version_from_parsed_lockfile(updated_lockfile)
|
85
|
+
end
|
86
|
+
rescue SharedHelpers::HelperSubprocessFailed => error
|
87
|
+
handle_pipenv_errors(error)
|
88
|
+
end
|
89
|
+
return unless @latest_resolvable_version_string
|
90
|
+
|
91
|
+
Python::Version.new(@latest_resolvable_version_string)
|
92
|
+
end
|
93
|
+
|
94
|
+
def fetch_version_from_parsed_lockfile(updated_lockfile)
|
95
|
+
if dependency.requirements.any?
|
96
|
+
group = dependency.requirements.first[:groups].first
|
97
|
+
deps = updated_lockfile[group] || {}
|
98
|
+
|
99
|
+
version =
|
100
|
+
deps.transform_keys { |k| normalise(k) }.
|
101
|
+
dig(dependency.name, "version")&.
|
102
|
+
gsub(/^==/, "")
|
103
|
+
|
104
|
+
return version
|
105
|
+
end
|
106
|
+
|
107
|
+
Python::FileParser::DEPENDENCY_GROUP_KEYS.each do |keys|
|
108
|
+
deps = updated_lockfile[keys.fetch(:lockfile)] || {}
|
109
|
+
version =
|
110
|
+
deps.transform_keys { |k| normalise(k) }.
|
111
|
+
dig(dependency.name, "version")&.
|
112
|
+
gsub(/^==/, "")
|
113
|
+
|
114
|
+
return version if version
|
115
|
+
end
|
116
|
+
|
117
|
+
# If the sub-dependency no longer appears in the lockfile return nil
|
118
|
+
nil
|
119
|
+
end
|
120
|
+
|
121
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
122
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
123
|
+
# rubocop:disable Metrics/AbcSize
|
124
|
+
# rubocop:disable Metrics/MethodLength
|
125
|
+
def handle_pipenv_errors(error)
|
126
|
+
if error.message.include?("no version found at all") ||
|
127
|
+
error.message.include?("Invalid specifier:") ||
|
128
|
+
error.message.include?("Max retries exceeded")
|
129
|
+
msg = clean_error_message(error.message)
|
130
|
+
raise if msg.empty?
|
131
|
+
|
132
|
+
raise DependencyFileNotResolvable, msg
|
133
|
+
end
|
134
|
+
|
135
|
+
if error.message.match?(UNSUPPORTED_DEP_REGEX)
|
136
|
+
msg = "Dependabot detected a dependency that can't be built on "\
|
137
|
+
"linux. Currently, all Dependabot builds happen on linux "\
|
138
|
+
"boxes, so there is no way for Dependabot to resolve your "\
|
139
|
+
"dependency files.\n\n"\
|
140
|
+
"Unless you think Dependabot has made a mistake (please "\
|
141
|
+
"tag us if so) you may wish to disable Dependabot on this "\
|
142
|
+
"repo."
|
143
|
+
raise DependencyFileNotResolvable, msg
|
144
|
+
end
|
145
|
+
|
146
|
+
if error.message.include?("Could not find a version") ||
|
147
|
+
error.message.include?("is not a python version")
|
148
|
+
check_original_requirements_resolvable
|
149
|
+
end
|
150
|
+
|
151
|
+
if error.message.include?('Command "python setup.py egg_info"') &&
|
152
|
+
error.message.include?(dependency.name)
|
153
|
+
# The latest version of the dependency we're updating is borked
|
154
|
+
# (because it has an unevaluatable setup.py). Skip the update.
|
155
|
+
return nil
|
156
|
+
end
|
157
|
+
|
158
|
+
if error.message.match?(GIT_DEPENDENCY_UNREACHABLE_REGEX)
|
159
|
+
url = error.message.match(GIT_DEPENDENCY_UNREACHABLE_REGEX).
|
160
|
+
named_captures.fetch("url")
|
161
|
+
raise GitDependenciesNotReachable, url
|
162
|
+
end
|
163
|
+
|
164
|
+
if error.message.match?(GIT_REFERENCE_NOT_FOUND_REGEX)
|
165
|
+
name = error.message.match(GIT_REFERENCE_NOT_FOUND_REGEX).
|
166
|
+
named_captures.fetch("name")
|
167
|
+
raise GitDependencyReferenceNotFound, name
|
168
|
+
end
|
169
|
+
|
170
|
+
raise unless error.message.include?("could not be resolved")
|
171
|
+
end
|
172
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
173
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
174
|
+
# rubocop:enable Metrics/AbcSize
|
175
|
+
# rubocop:enable Metrics/MethodLength
|
176
|
+
|
177
|
+
# Needed because Pipenv's resolver isn't perfect.
|
178
|
+
# Note: We raise errors from this method, rather than returning a
|
179
|
+
# boolean, so that all deps for this repo will raise identical
|
180
|
+
# errors when failing to update
|
181
|
+
def check_original_requirements_resolvable
|
182
|
+
SharedHelpers.in_a_temporary_directory do
|
183
|
+
SharedHelpers.with_git_configured(credentials: credentials) do
|
184
|
+
write_temporary_dependency_files(update_pipfile: false)
|
185
|
+
|
186
|
+
run_pipenv_command(
|
187
|
+
pipenv_environment_variables + "pyenv exec pipenv lock"
|
188
|
+
)
|
189
|
+
|
190
|
+
true
|
191
|
+
rescue SharedHelpers::HelperSubprocessFailed => error
|
192
|
+
if error.message.include?("Could not find a version")
|
193
|
+
msg = clean_error_message(error.message)
|
194
|
+
msg.gsub!(/\s+\(from .*$/, "")
|
195
|
+
raise if msg.empty?
|
196
|
+
|
197
|
+
raise DependencyFileNotResolvable, msg
|
198
|
+
end
|
199
|
+
|
200
|
+
if error.message.include?("is not a python version")
|
201
|
+
msg = "Pipenv does not support specifying Python ranges "\
|
202
|
+
"(see https://github.com/pypa/pipenv/issues/1050 for more "\
|
203
|
+
"details)."
|
204
|
+
raise DependencyFileNotResolvable, msg
|
205
|
+
end
|
206
|
+
|
207
|
+
raise
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
def clean_error_message(message)
|
213
|
+
# Pipenv outputs a lot of things to STDERR, so we need to clean
|
214
|
+
# up the error message
|
215
|
+
msg_lines = message.lines
|
216
|
+
msg = msg_lines.
|
217
|
+
take_while { |l| !l.start_with?("During handling of") }.
|
218
|
+
drop_while do |l|
|
219
|
+
next false if l.start_with?("CRITICAL:")
|
220
|
+
next false if l.start_with?("ERROR:")
|
221
|
+
next false if l.start_with?("packaging.specifiers")
|
222
|
+
next false if l.include?("Max retries exceeded")
|
223
|
+
|
224
|
+
true
|
225
|
+
end.join.strip
|
226
|
+
|
227
|
+
# We also need to redact any URLs, as they may include credentials
|
228
|
+
msg.gsub(/http.*?(?=\s)/, "<redacted>")
|
229
|
+
end
|
230
|
+
|
231
|
+
def write_temporary_dependency_files(update_pipfile: true)
|
232
|
+
dependency_files.each do |file|
|
233
|
+
next if file.name == ".python-version"
|
234
|
+
|
235
|
+
path = file.name
|
236
|
+
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
237
|
+
File.write(path, file.content)
|
238
|
+
end
|
239
|
+
|
240
|
+
setup_files.each do |file|
|
241
|
+
path = file.name
|
242
|
+
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
243
|
+
File.write(path, sanitized_setup_file_content(file))
|
244
|
+
end
|
245
|
+
|
246
|
+
setup_cfg_files.each do |file|
|
247
|
+
path = file.name
|
248
|
+
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
249
|
+
File.write(path, "[metadata]\nname = sanitized-package\n")
|
250
|
+
end
|
251
|
+
|
252
|
+
# Overwrite the pipfile with updated content
|
253
|
+
File.write(
|
254
|
+
"Pipfile",
|
255
|
+
pipfile_content(update_pipfile: update_pipfile)
|
256
|
+
)
|
257
|
+
end
|
258
|
+
|
259
|
+
def sanitized_setup_file_content(file)
|
260
|
+
@sanitized_setup_file_content ||= {}
|
261
|
+
@sanitized_setup_file_content[file.name] ||=
|
262
|
+
Python::FileUpdater::SetupFileSanitizer.
|
263
|
+
new(setup_file: file, setup_cfg: setup_cfg(file)).
|
264
|
+
sanitized_content
|
265
|
+
end
|
266
|
+
|
267
|
+
def setup_cfg(file)
|
268
|
+
config_name = file.name.sub(/\.py$/, ".cfg")
|
269
|
+
dependency_files.find { |f| f.name == config_name }
|
270
|
+
end
|
271
|
+
|
272
|
+
def pipfile_content(update_pipfile: true)
|
273
|
+
content = pipfile.content
|
274
|
+
return content unless update_pipfile
|
275
|
+
|
276
|
+
content = freeze_other_dependencies(content)
|
277
|
+
content = unlock_target_dependency(content) if unlock_requirement?
|
278
|
+
content = add_private_sources(content)
|
279
|
+
content
|
280
|
+
end
|
281
|
+
|
282
|
+
def freeze_other_dependencies(pipfile_content)
|
283
|
+
Python::FileUpdater::PipfilePreparer.
|
284
|
+
new(pipfile_content: pipfile_content).
|
285
|
+
freeze_top_level_dependencies_except([dependency], lockfile)
|
286
|
+
end
|
287
|
+
|
288
|
+
def unlock_target_dependency(pipfile_content)
|
289
|
+
pipfile_object = TomlRB.parse(pipfile_content)
|
290
|
+
|
291
|
+
%w(packages dev-packages).each do |type|
|
292
|
+
names = pipfile_object[type]&.keys || []
|
293
|
+
pkg_name = names.find { |nm| normalise(nm) == dependency.name }
|
294
|
+
next unless pkg_name
|
295
|
+
|
296
|
+
if pipfile_object.dig(type, pkg_name).is_a?(Hash)
|
297
|
+
pipfile_object[type][pkg_name]["version"] =
|
298
|
+
updated_version_requirement_string
|
299
|
+
else
|
300
|
+
pipfile_object[type][pkg_name] =
|
301
|
+
updated_version_requirement_string
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
TomlRB.dump(pipfile_object)
|
306
|
+
end
|
307
|
+
|
308
|
+
def add_private_sources(pipfile_content)
|
309
|
+
Python::FileUpdater::PipfilePreparer.
|
310
|
+
new(pipfile_content: pipfile_content).
|
311
|
+
replace_sources(credentials)
|
312
|
+
end
|
313
|
+
|
314
|
+
def add_python_two_requirement_to_pipfile
|
315
|
+
content = File.read("Pipfile")
|
316
|
+
|
317
|
+
updated_content =
|
318
|
+
Python::FileUpdater::PipfilePreparer.
|
319
|
+
new(pipfile_content: content).
|
320
|
+
update_python_requirement("2.7.15")
|
321
|
+
|
322
|
+
File.write("Pipfile", updated_content)
|
323
|
+
end
|
324
|
+
|
325
|
+
def pipfile_python_requirement
|
326
|
+
parsed_pipfile = TomlRB.parse(pipfile.content)
|
327
|
+
|
328
|
+
parsed_pipfile.dig("requires", "python_full_version") ||
|
329
|
+
parsed_pipfile.dig("requires", "python_version")
|
330
|
+
end
|
331
|
+
|
332
|
+
def python_requirement_specified?
|
333
|
+
return true if pipfile_python_requirement
|
334
|
+
|
335
|
+
!python_version_file.nil?
|
336
|
+
end
|
337
|
+
|
338
|
+
def set_up_python_environment
|
339
|
+
# Initialize a git repo to appease pip-tools
|
340
|
+
begin
|
341
|
+
SharedHelpers.run_shell_command("git init") if setup_files.any?
|
342
|
+
rescue Dependabot::SharedHelpers::HelperSubprocessFailed
|
343
|
+
nil
|
344
|
+
end
|
345
|
+
|
346
|
+
SharedHelpers.run_shell_command(
|
347
|
+
"pyenv install -s #{python_version}"
|
348
|
+
)
|
349
|
+
SharedHelpers.run_shell_command("pyenv local #{python_version}")
|
350
|
+
return if pre_installed_python?(python_version)
|
351
|
+
|
352
|
+
SharedHelpers.run_shell_command(
|
353
|
+
"pyenv exec pip install -r #{python_requirements_path}"
|
354
|
+
)
|
355
|
+
end
|
356
|
+
|
357
|
+
def python_version
|
358
|
+
requirement =
|
359
|
+
if @using_python_two
|
360
|
+
"2.7.*"
|
361
|
+
elsif pipfile_python_requirement&.match?(/^\d/)
|
362
|
+
parts = pipfile_python_requirement.split(".")
|
363
|
+
parts.fill("*", (parts.length)..2).join(".")
|
364
|
+
elsif python_version_file
|
365
|
+
python_version_file.content
|
366
|
+
else
|
367
|
+
PythonVersions::PRE_INSTALLED_PYTHON_VERSIONS.first
|
368
|
+
end
|
369
|
+
|
370
|
+
requirement = Python::Requirement.new(requirement)
|
371
|
+
|
372
|
+
PythonVersions::PYTHON_VERSIONS.find do |version|
|
373
|
+
requirement.satisfied_by?(Python::Version.new(version))
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
def pre_installed_python?(version)
|
378
|
+
PythonVersions::PRE_INSTALLED_PYTHON_VERSIONS.include?(version)
|
379
|
+
end
|
380
|
+
|
381
|
+
def check_private_sources_are_reachable
|
382
|
+
env_sources = pipfile_sources.select { |h| h["url"].include?("${") }
|
383
|
+
|
384
|
+
check_env_sources_included_in_config_variables(env_sources)
|
385
|
+
|
386
|
+
sources_to_check =
|
387
|
+
pipfile_sources.reject { |h| h["url"].include?("${") } +
|
388
|
+
config_variable_sources
|
389
|
+
|
390
|
+
sources_to_check.
|
391
|
+
map { |details| details["url"] }.
|
392
|
+
reject { |url| MAIN_PYPI_INDEXES.include?(url) }.
|
393
|
+
each do |url|
|
394
|
+
sanitized_url = url.gsub(%r{(?<=//).*(?=@)}, "redacted")
|
395
|
+
|
396
|
+
response = Excon.get(
|
397
|
+
url,
|
398
|
+
idempotent: true,
|
399
|
+
**SharedHelpers.excon_defaults
|
400
|
+
)
|
401
|
+
|
402
|
+
if response.status == 401 || response.status == 403
|
403
|
+
raise PrivateSourceAuthenticationFailure, sanitized_url
|
404
|
+
end
|
405
|
+
rescue Excon::Error::Timeout, Excon::Error::Socket
|
406
|
+
raise PrivateSourceTimedOut, sanitized_url
|
407
|
+
end
|
408
|
+
end
|
409
|
+
|
410
|
+
def updated_version_requirement_string
|
411
|
+
lower_bound_req = updated_version_req_lower_bound
|
412
|
+
|
413
|
+
# Add the latest_allowable_version as an upper bound. This means
|
414
|
+
# ignore conditions are considered when checking for the latest
|
415
|
+
# resolvable version.
|
416
|
+
#
|
417
|
+
# NOTE: This isn't perfect. If v2.x is ignored and v3 is out but
|
418
|
+
# unresolvable then the `latest_allowable_version` will be v3, and
|
419
|
+
# we won't be ignoring v2.x releases like we should be.
|
420
|
+
return lower_bound_req if latest_allowable_version.nil?
|
421
|
+
unless Python::Version.correct?(latest_allowable_version)
|
422
|
+
return lower_bound_req
|
423
|
+
end
|
424
|
+
|
425
|
+
lower_bound_req + ", <= #{latest_allowable_version}"
|
426
|
+
end
|
427
|
+
|
428
|
+
def updated_version_req_lower_bound
|
429
|
+
if dependency.version
|
430
|
+
">= #{dependency.version}"
|
431
|
+
else
|
432
|
+
version_for_requirement =
|
433
|
+
dependency.requirements.map { |r| r[:requirement] }.compact.
|
434
|
+
reject { |req_string| req_string.start_with?("<") }.
|
435
|
+
select { |req_string| req_string.match?(VERSION_REGEX) }.
|
436
|
+
map { |req_string| req_string.match(VERSION_REGEX) }.
|
437
|
+
select { |version| Gem::Version.correct?(version) }.
|
438
|
+
max_by { |version| Gem::Version.new(version) }
|
439
|
+
|
440
|
+
">= #{version_for_requirement || 0}"
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
def run_pipenv_command(cmd)
|
445
|
+
set_up_python_environment
|
446
|
+
|
447
|
+
raw_response = nil
|
448
|
+
IO.popen(cmd, err: %i(child out)) { |p| raw_response = p.read }
|
449
|
+
|
450
|
+
# Raise an error with the output from the shell session if Pipenv
|
451
|
+
# returns a non-zero status
|
452
|
+
return if $CHILD_STATUS.success?
|
453
|
+
|
454
|
+
raise SharedHelpers::HelperSubprocessFailed.new(raw_response, cmd)
|
455
|
+
rescue SharedHelpers::HelperSubprocessFailed => error
|
456
|
+
original_error ||= error
|
457
|
+
msg = error.message
|
458
|
+
|
459
|
+
relevant_error =
|
460
|
+
if may_be_using_wrong_python_version?(msg) then original_error
|
461
|
+
else error
|
462
|
+
end
|
463
|
+
|
464
|
+
raise relevant_error unless may_be_using_wrong_python_version?(msg)
|
465
|
+
raise relevant_error if @using_python_two
|
466
|
+
|
467
|
+
@using_python_two = true
|
468
|
+
add_python_two_requirement_to_pipfile
|
469
|
+
cmd = cmd.gsub("pipenv ", "pipenv --two ")
|
470
|
+
retry
|
471
|
+
ensure
|
472
|
+
@using_python_two = nil
|
473
|
+
end
|
474
|
+
|
475
|
+
def may_be_using_wrong_python_version?(error_message)
|
476
|
+
return false if python_requirement_specified?
|
477
|
+
return true if error_message.include?("UnsupportedPythonVersion")
|
478
|
+
|
479
|
+
error_message.include?('Command "python setup.py egg_info" failed')
|
480
|
+
end
|
481
|
+
|
482
|
+
def check_env_sources_included_in_config_variables(env_sources)
|
483
|
+
config_variable_source_urls =
|
484
|
+
config_variable_sources.map { |s| s["url"] }
|
485
|
+
|
486
|
+
env_sources.each do |source|
|
487
|
+
url = source["url"]
|
488
|
+
known_parts = url.split(/\$\{.*?\}/).reject(&:empty?).compact
|
489
|
+
|
490
|
+
# If the whole URL is an environment variable we can't do a check
|
491
|
+
next if known_parts.none?
|
492
|
+
|
493
|
+
regex = known_parts.map { |p| Regexp.quote(p) }.join(".*?")
|
494
|
+
next if config_variable_source_urls.any? { |s| s.match?(regex) }
|
495
|
+
|
496
|
+
raise PrivateSourceAuthenticationFailure, url
|
497
|
+
end
|
498
|
+
end
|
499
|
+
|
500
|
+
def config_variable_sources
|
501
|
+
@config_variable_sources ||=
|
502
|
+
credentials.
|
503
|
+
select { |cred| cred["type"] == "python_index" }.
|
504
|
+
map { |h| { "url" => h["index-url"].gsub(%r{/*$}, "") + "/" } }
|
505
|
+
end
|
506
|
+
|
507
|
+
def pipfile_sources
|
508
|
+
@pipfile_sources ||=
|
509
|
+
TomlRB.parse(pipfile.content).fetch("source", []).
|
510
|
+
map { |h| h.dup.merge("url" => h["url"].gsub(%r{/*$}, "") + "/") }
|
511
|
+
end
|
512
|
+
|
513
|
+
def pipenv_environment_variables
|
514
|
+
environment_variables = [
|
515
|
+
"PIPENV_YES=true", # Install new Python versions if needed
|
516
|
+
"PIPENV_MAX_RETRIES=3", # Retry timeouts
|
517
|
+
"PIPENV_NOSPIN=1", # Don't pollute logs with spinner
|
518
|
+
"PIPENV_TIMEOUT=600", # Set install timeout to 10 minutes
|
519
|
+
"PIP_DEFAULT_TIMEOUT=60" # Set pip timeout to 1 minute
|
520
|
+
]
|
521
|
+
|
522
|
+
environment_variables.join(" ") + " "
|
523
|
+
end
|
524
|
+
|
525
|
+
def python_requirements_path
|
526
|
+
project_root = File.join(File.dirname(__FILE__), "../../../../..")
|
527
|
+
File.join(project_root, "helpers/python/requirements.txt")
|
528
|
+
end
|
529
|
+
|
530
|
+
# See https://www.python.org/dev/peps/pep-0503/#normalized-names
|
531
|
+
def normalise(name)
|
532
|
+
name.downcase.gsub(/[-_.]+/, "-")
|
533
|
+
end
|
534
|
+
|
535
|
+
def pipfile
|
536
|
+
dependency_files.find { |f| f.name == "Pipfile" }
|
537
|
+
end
|
538
|
+
|
539
|
+
def lockfile
|
540
|
+
dependency_files.find { |f| f.name == "Pipfile.lock" }
|
541
|
+
end
|
542
|
+
|
543
|
+
def setup_files
|
544
|
+
dependency_files.select { |f| f.name.end_with?("setup.py") }
|
545
|
+
end
|
546
|
+
|
547
|
+
def setup_cfg_files
|
548
|
+
dependency_files.select { |f| f.name.end_with?("setup.cfg") }
|
549
|
+
end
|
550
|
+
|
551
|
+
def python_version_file
|
552
|
+
dependency_files.find { |f| f.name == ".python-version" }
|
553
|
+
end
|
554
|
+
end
|
555
|
+
end
|
556
|
+
end
|
557
|
+
end
|
558
|
+
# rubocop:enable Metrics/ClassLength
|