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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/helpers/build +17 -0
  3. data/helpers/lib/__init__.py +0 -0
  4. data/helpers/lib/hasher.py +23 -0
  5. data/helpers/lib/parser.py +130 -0
  6. data/helpers/requirements.txt +9 -0
  7. data/helpers/run.py +18 -0
  8. data/lib/dependabot/python.rb +11 -0
  9. data/lib/dependabot/python/file_fetcher.rb +307 -0
  10. data/lib/dependabot/python/file_parser.rb +221 -0
  11. data/lib/dependabot/python/file_parser/pipfile_files_parser.rb +150 -0
  12. data/lib/dependabot/python/file_parser/poetry_files_parser.rb +139 -0
  13. data/lib/dependabot/python/file_parser/setup_file_parser.rb +158 -0
  14. data/lib/dependabot/python/file_updater.rb +149 -0
  15. data/lib/dependabot/python/file_updater/pip_compile_file_updater.rb +361 -0
  16. data/lib/dependabot/python/file_updater/pipfile_file_updater.rb +391 -0
  17. data/lib/dependabot/python/file_updater/pipfile_preparer.rb +123 -0
  18. data/lib/dependabot/python/file_updater/poetry_file_updater.rb +282 -0
  19. data/lib/dependabot/python/file_updater/pyproject_preparer.rb +103 -0
  20. data/lib/dependabot/python/file_updater/requirement_file_updater.rb +160 -0
  21. data/lib/dependabot/python/file_updater/requirement_replacer.rb +93 -0
  22. data/lib/dependabot/python/file_updater/setup_file_sanitizer.rb +89 -0
  23. data/lib/dependabot/python/metadata_finder.rb +122 -0
  24. data/lib/dependabot/python/native_helpers.rb +17 -0
  25. data/lib/dependabot/python/python_versions.rb +25 -0
  26. data/lib/dependabot/python/requirement.rb +129 -0
  27. data/lib/dependabot/python/requirement_parser.rb +38 -0
  28. data/lib/dependabot/python/update_checker.rb +229 -0
  29. data/lib/dependabot/python/update_checker/latest_version_finder.rb +250 -0
  30. data/lib/dependabot/python/update_checker/pip_compile_version_resolver.rb +379 -0
  31. data/lib/dependabot/python/update_checker/pipfile_version_resolver.rb +558 -0
  32. data/lib/dependabot/python/update_checker/poetry_version_resolver.rb +298 -0
  33. data/lib/dependabot/python/update_checker/requirements_updater.rb +365 -0
  34. data/lib/dependabot/python/version.rb +87 -0
  35. 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