dependabot-python 0.79.0

Sign up to get free protection for your applications and to get access to all the features.
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,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+
5
+ require "dependabot/python/requirement_parser"
6
+ require "dependabot/python/file_updater"
7
+ require "dependabot/shared_helpers"
8
+ require "dependabot/python/native_helpers"
9
+
10
+ # rubocop:disable Metrics/ClassLength
11
+ module Dependabot
12
+ module Python
13
+ class FileUpdater
14
+ class PipfileFileUpdater
15
+ require_relative "pipfile_preparer"
16
+ require_relative "setup_file_sanitizer"
17
+
18
+ attr_reader :dependencies, :dependency_files, :credentials
19
+
20
+ def initialize(dependencies:, dependency_files:, credentials:)
21
+ @dependencies = dependencies
22
+ @dependency_files = dependency_files
23
+ @credentials = credentials
24
+ end
25
+
26
+ def updated_dependency_files
27
+ return @updated_dependency_files if @update_already_attempted
28
+
29
+ @update_already_attempted = true
30
+ @updated_dependency_files ||= fetch_updated_dependency_files
31
+ end
32
+
33
+ private
34
+
35
+ def dependency
36
+ # For now, we'll only ever be updating a single dependency
37
+ dependencies.first
38
+ end
39
+
40
+ def fetch_updated_dependency_files
41
+ updated_files = []
42
+
43
+ if file_changed?(pipfile)
44
+ updated_files <<
45
+ updated_file(file: pipfile, content: updated_pipfile_content)
46
+ end
47
+
48
+ if lockfile
49
+ if lockfile.content == updated_lockfile_content
50
+ raise "Expected Pipfile.lock to change!"
51
+ end
52
+
53
+ updated_files <<
54
+ updated_file(file: lockfile, content: updated_lockfile_content)
55
+ end
56
+
57
+ updated_files += updated_generated_requirements_files
58
+ updated_files
59
+ end
60
+
61
+ def updated_pipfile_content
62
+ dependencies.
63
+ select { |dep| requirement_changed?(pipfile, dep) }.
64
+ reduce(pipfile.content.dup) do |content, dep|
65
+ updated_requirement =
66
+ dep.requirements.find { |r| r[:file] == pipfile.name }.
67
+ fetch(:requirement)
68
+
69
+ old_req =
70
+ dep.previous_requirements.
71
+ find { |r| r[:file] == pipfile.name }.
72
+ fetch(:requirement)
73
+
74
+ updated_content =
75
+ content.gsub(declaration_regex(dep)) do |line|
76
+ line.gsub(old_req, updated_requirement)
77
+ end
78
+
79
+ raise "Content did not change!" if content == updated_content
80
+
81
+ updated_content
82
+ end
83
+ end
84
+
85
+ def updated_lockfile_content
86
+ @updated_lockfile_content ||=
87
+ updated_generated_files.fetch(:lockfile)
88
+ end
89
+
90
+ def generate_updated_requirements_files?
91
+ return true if generated_requirements_files("default").any?
92
+
93
+ generated_requirements_files("develop").any?
94
+ end
95
+
96
+ def generated_requirements_files(type)
97
+ return [] unless lockfile
98
+
99
+ pipfile_lock_deps = parsed_lockfile[type]&.keys&.sort || []
100
+ pipfile_lock_deps = pipfile_lock_deps.map { |n| normalise(n) }
101
+
102
+ regex = RequirementParser::INSTALL_REQ_WITH_REQUIREMENT
103
+
104
+ # Find any requirement files that list the same dependencies as
105
+ # the (old) Pipfile.lock. Any such files were almost certainly
106
+ # generated using `pipenv lock -r`
107
+ requirements_files.select do |req_file|
108
+ deps = []
109
+ req_file.content.scan(regex) { deps << Regexp.last_match }
110
+ deps = deps.map { |m| normalise(m[:name]) }
111
+ deps.sort == pipfile_lock_deps
112
+ end
113
+ end
114
+
115
+ def updated_generated_requirements_files
116
+ updated_files = []
117
+
118
+ generated_requirements_files("default").each do |file|
119
+ next if file.content == updated_req_content
120
+
121
+ updated_files <<
122
+ updated_file(file: file, content: updated_req_content)
123
+ end
124
+
125
+ generated_requirements_files("develop").each do |file|
126
+ next if file.content == updated_dev_req_content
127
+
128
+ updated_files <<
129
+ updated_file(file: file, content: updated_dev_req_content)
130
+ end
131
+
132
+ updated_files
133
+ end
134
+
135
+ def updated_req_content
136
+ updated_generated_files.fetch(:requirements_txt)
137
+ end
138
+
139
+ def updated_dev_req_content
140
+ updated_generated_files.fetch(:dev_requirements_txt)
141
+ end
142
+
143
+ def prepared_pipfile_content
144
+ content = updated_pipfile_content
145
+ content = freeze_other_dependencies(content)
146
+ content = freeze_dependencies_being_updated(content)
147
+ content = add_private_sources(content)
148
+ content
149
+ end
150
+
151
+ def freeze_other_dependencies(pipfile_content)
152
+ PipfilePreparer.
153
+ new(pipfile_content: pipfile_content).
154
+ freeze_top_level_dependencies_except(dependencies, lockfile)
155
+ end
156
+
157
+ def freeze_dependencies_being_updated(pipfile_content)
158
+ pipfile_object = TomlRB.parse(pipfile_content)
159
+
160
+ dependencies.each do |dep|
161
+ %w(packages dev-packages).each do |type|
162
+ names = pipfile_object[type]&.keys || []
163
+ pkg_name = names.find { |nm| normalise(nm) == dep.name }
164
+ next unless pkg_name
165
+
166
+ if pipfile_object[type][pkg_name].is_a?(Hash)
167
+ pipfile_object[type][pkg_name]["version"] =
168
+ "==#{dep.version}"
169
+ else
170
+ pipfile_object[type][pkg_name] = "==#{dep.version}"
171
+ end
172
+ end
173
+ end
174
+
175
+ TomlRB.dump(pipfile_object)
176
+ end
177
+
178
+ def add_private_sources(pipfile_content)
179
+ PipfilePreparer.
180
+ new(pipfile_content: pipfile_content).
181
+ replace_sources(credentials)
182
+ end
183
+
184
+ def updated_generated_files
185
+ @updated_generated_files ||=
186
+ SharedHelpers.in_a_temporary_directory do
187
+ SharedHelpers.with_git_configured(credentials: credentials) do
188
+ write_temporary_dependency_files(prepared_pipfile_content)
189
+
190
+ # Initialize a git repo to appease pip-tools
191
+ IO.popen("git init", err: %i(child out)) if setup_files.any?
192
+
193
+ run_pipenv_command(
194
+ pipenv_environment_variables + "pyenv exec pipenv lock"
195
+ )
196
+
197
+ result = { lockfile: File.read("Pipfile.lock") }
198
+ result[:lockfile] = post_process_lockfile(result[:lockfile])
199
+
200
+ # Generate updated requirement.txt entries, if needed.
201
+ if generate_updated_requirements_files?
202
+ generate_updated_requirements_files
203
+
204
+ result[:requirements_txt] = File.read("req.txt")
205
+ result[:dev_requirements_txt] = File.read("dev-req.txt")
206
+ end
207
+
208
+ result
209
+ end
210
+ end
211
+ end
212
+
213
+ def post_process_lockfile(updated_lockfile_content)
214
+ pipfile_hash = pipfile_hash_for(updated_pipfile_content)
215
+ original_reqs = parsed_lockfile["_meta"]["requires"]
216
+ original_source = parsed_lockfile["_meta"]["sources"]
217
+
218
+ new_lockfile = updated_lockfile_content.dup
219
+ new_lockfile_json = JSON.parse(new_lockfile)
220
+ new_lockfile_json["_meta"]["hash"]["sha256"] = pipfile_hash
221
+ new_lockfile_json["_meta"]["requires"] = original_reqs
222
+ new_lockfile_json["_meta"]["sources"] = original_source
223
+
224
+ JSON.pretty_generate(new_lockfile_json, indent: " ").
225
+ gsub(/\{\n\s*\}/, "{}").
226
+ gsub(/\}\z/, "}\n")
227
+ end
228
+
229
+ def generate_updated_requirements_files
230
+ run_pipenv_command(
231
+ pipenv_environment_variables +
232
+ "pyenv exec pipenv lock -r > req.txt"
233
+ )
234
+ run_pipenv_command(
235
+ pipenv_environment_variables +
236
+ "pyenv exec pipenv lock -r -d > dev-req.txt"
237
+ )
238
+ end
239
+
240
+ def run_pipenv_command(cmd)
241
+ raw_response = nil
242
+ IO.popen(cmd, err: %i(child out)) { |p| raw_response = p.read }
243
+
244
+ # Raise an error with the output from the shell session if Pipenv
245
+ # returns a non-zero status
246
+ return if $CHILD_STATUS.success?
247
+
248
+ raise SharedHelpers::HelperSubprocessFailed.new(raw_response, cmd)
249
+ rescue SharedHelpers::HelperSubprocessFailed => error
250
+ original_error ||= error
251
+ msg = error.message
252
+
253
+ relevant_error =
254
+ if error_suggests_bad_python_version?(msg) then original_error
255
+ else error
256
+ end
257
+
258
+ raise relevant_error unless error_suggests_bad_python_version?(msg)
259
+ raise relevant_error if cmd.include?("--two")
260
+
261
+ cmd = cmd.gsub("pipenv ", "pipenv --two ")
262
+ retry
263
+ end
264
+
265
+ def error_suggests_bad_python_version?(message)
266
+ return true if message.include?("UnsupportedPythonVersion")
267
+
268
+ message.include?('Command "python setup.py egg_info" failed')
269
+ end
270
+
271
+ def write_temporary_dependency_files(pipfile_content)
272
+ dependency_files.each do |file|
273
+ next if file.name == ".python-version"
274
+
275
+ path = file.name
276
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
277
+ File.write(path, file.content)
278
+ end
279
+
280
+ setup_files.each do |file|
281
+ path = file.name
282
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
283
+ File.write(path, sanitized_setup_file_content(file))
284
+ end
285
+
286
+ setup_cfg_files.each do |file|
287
+ path = file.name
288
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
289
+ File.write(path, "[metadata]\nname = sanitized-package\n")
290
+ end
291
+
292
+ # Overwrite the pipfile with updated content
293
+ File.write("Pipfile", pipfile_content)
294
+ end
295
+
296
+ def sanitized_setup_file_content(file)
297
+ @sanitized_setup_file_content ||= {}
298
+ if @sanitized_setup_file_content[file.name]
299
+ return @sanitized_setup_file_content[file.name]
300
+ end
301
+
302
+ @sanitized_setup_file_content[file.name] =
303
+ SetupFileSanitizer.
304
+ new(setup_file: file, setup_cfg: setup_cfg(file)).
305
+ sanitized_content
306
+ end
307
+
308
+ def setup_cfg(file)
309
+ dependency_files.find do |f|
310
+ f.name == file.name.sub(/\.py$/, ".cfg")
311
+ end
312
+ end
313
+
314
+ def pipfile_hash_for(pipfile_content)
315
+ SharedHelpers.in_a_temporary_directory do |dir|
316
+ File.write(File.join(dir, "Pipfile"), pipfile_content)
317
+ SharedHelpers.run_helper_subprocess(
318
+ command: "pyenv exec python #{NativeHelpers.python_helper_path}",
319
+ function: "get_pipfile_hash",
320
+ args: [dir]
321
+ )
322
+ end
323
+ end
324
+
325
+ def declaration_regex(dep)
326
+ escaped_name = Regexp.escape(dep.name).gsub("\\-", "[-_.]")
327
+ /(?:^|["'])#{escaped_name}["']?\s*=.*$/i
328
+ end
329
+
330
+ def file_changed?(file)
331
+ dependencies.any? { |dep| requirement_changed?(file, dep) }
332
+ end
333
+
334
+ def requirement_changed?(file, dependency)
335
+ changed_requirements =
336
+ dependency.requirements - dependency.previous_requirements
337
+
338
+ changed_requirements.any? { |f| f[:file] == file.name }
339
+ end
340
+
341
+ def updated_file(file:, content:)
342
+ updated_file = file.dup
343
+ updated_file.content = content
344
+ updated_file
345
+ end
346
+
347
+ # See https://www.python.org/dev/peps/pep-0503/#normalized-names
348
+ def normalise(name)
349
+ name.downcase.gsub(/[-_.]+/, "-")
350
+ end
351
+
352
+ def parsed_lockfile
353
+ @parsed_lockfile ||= JSON.parse(lockfile.content)
354
+ end
355
+
356
+ def pipfile
357
+ @pipfile ||= dependency_files.find { |f| f.name == "Pipfile" }
358
+ end
359
+
360
+ def lockfile
361
+ @lockfile ||= dependency_files.find { |f| f.name == "Pipfile.lock" }
362
+ end
363
+
364
+ def setup_files
365
+ dependency_files.select { |f| f.name.end_with?("setup.py") }
366
+ end
367
+
368
+ def setup_cfg_files
369
+ dependency_files.select { |f| f.name.end_with?("setup.cfg") }
370
+ end
371
+
372
+ def requirements_files
373
+ dependency_files.select { |f| f.name.end_with?(".txt") }
374
+ end
375
+
376
+ def pipenv_environment_variables
377
+ environment_variables = [
378
+ "PIPENV_YES=true", # Install new Python versions if needed
379
+ "PIPENV_MAX_RETRIES=3", # Retry timeouts
380
+ "PIPENV_NOSPIN=1", # Don't pollute logs with spinner
381
+ "PIPENV_TIMEOUT=600", # Set install timeout to 10 minutes
382
+ "PIP_DEFAULT_TIMEOUT=60" # Set pip timeout to 1 minute
383
+ ]
384
+
385
+ environment_variables.join(" ") + " "
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
391
+ # rubocop:enable Metrics/ClassLength
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+
5
+ require "dependabot/python/file_parser"
6
+ require "dependabot/python/file_updater"
7
+
8
+ module Dependabot
9
+ module Python
10
+ class FileUpdater
11
+ class PipfilePreparer
12
+ def initialize(pipfile_content:)
13
+ @pipfile_content = pipfile_content
14
+ end
15
+
16
+ def replace_sources(credentials)
17
+ pipfile_object = TomlRB.parse(pipfile_content)
18
+
19
+ pipfile_object["source"] =
20
+ pipfile_sources.reject { |h| h["url"].include?("${") } +
21
+ config_variable_sources(credentials)
22
+
23
+ TomlRB.dump(pipfile_object)
24
+ end
25
+
26
+ def freeze_top_level_dependencies_except(dependencies, lockfile)
27
+ return pipfile_content unless lockfile
28
+
29
+ pipfile_object = TomlRB.parse(pipfile_content)
30
+ excluded_names = dependencies.map(&:name)
31
+
32
+ Python::FileParser::DEPENDENCY_GROUP_KEYS.each do |keys|
33
+ next unless pipfile_object[keys[:pipfile]]
34
+
35
+ pipfile_object.fetch(keys[:pipfile]).each do |dep_name, _|
36
+ next if excluded_names.include?(normalise(dep_name))
37
+
38
+ freeze_dependency(dep_name, pipfile_object, lockfile, keys)
39
+ end
40
+ end
41
+
42
+ TomlRB.dump(pipfile_object)
43
+ end
44
+
45
+ # rubocop:disable Metrics/PerceivedComplexity
46
+ def freeze_dependency(dep_name, pipfile_object, lockfile, keys)
47
+ locked_version = version_from_lockfile(
48
+ lockfile,
49
+ keys[:lockfile],
50
+ normalise(dep_name)
51
+ )
52
+ locked_ref = ref_from_lockfile(
53
+ lockfile,
54
+ keys[:lockfile],
55
+ normalise(dep_name)
56
+ )
57
+
58
+ pipfile_req = pipfile_object[keys[:pipfile]][dep_name]
59
+ if pipfile_req.is_a?(Hash) && locked_version
60
+ pipfile_req["version"] = "==#{locked_version}"
61
+ elsif pipfile_req.is_a?(Hash) && locked_ref && !pipfile_req["ref"]
62
+ pipfile_req["ref"] = locked_ref
63
+ elsif locked_version
64
+ pipfile_object[keys[:pipfile]][dep_name] = "==#{locked_version}"
65
+ end
66
+ end
67
+ # rubocop:enable Metrics/PerceivedComplexity
68
+
69
+ def update_python_requirement(requirement)
70
+ pipfile_object = TomlRB.parse(pipfile_content)
71
+
72
+ pipfile_object["requires"] ||= {}
73
+ pipfile_object["requires"].delete("python_full_version")
74
+ pipfile_object["requires"].delete("python_version")
75
+ pipfile_object["requires"]["python_full_version"] = requirement
76
+
77
+ TomlRB.dump(pipfile_object)
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :pipfile_content
83
+
84
+ def version_from_lockfile(lockfile, dep_type, dep_name)
85
+ details = JSON.parse(lockfile.content).
86
+ dig(dep_type, normalise(dep_name))
87
+
88
+ case details
89
+ when String then details.gsub(/^==/, "")
90
+ when Hash then details["version"]&.gsub(/^==/, "")
91
+ end
92
+ end
93
+
94
+ def ref_from_lockfile(lockfile, dep_type, dep_name)
95
+ details = JSON.parse(lockfile.content).
96
+ dig(dep_type, normalise(dep_name))
97
+
98
+ case details
99
+ when Hash then details["ref"]
100
+ end
101
+ end
102
+
103
+ # See https://www.python.org/dev/peps/pep-0503/#normalized-names
104
+ def normalise(name)
105
+ name.downcase.gsub(/[-_.]+/, "-")
106
+ end
107
+
108
+ def pipfile_sources
109
+ @pipfile_sources ||=
110
+ TomlRB.parse(pipfile_content).fetch("source", []).
111
+ map { |h| h.dup.merge("url" => h["url"].gsub(%r{/*$}, "") + "/") }
112
+ end
113
+
114
+ def config_variable_sources(credentials)
115
+ @config_variable_sources ||=
116
+ credentials.
117
+ select { |cred| cred["type"] == "python_index" }.
118
+ map { |cred| { "url" => cred["index-url"] } }
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end