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,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