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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+
5
+ require "dependabot/shared_helpers"
6
+ require "dependabot/python/version"
7
+ require "dependabot/python/requirement"
8
+ require "dependabot/python/python_versions"
9
+ require "dependabot/python/file_updater"
10
+ require "dependabot/python/native_helpers"
11
+
12
+ module Dependabot
13
+ module Python
14
+ class FileUpdater
15
+ class PoetryFileUpdater
16
+ require_relative "pyproject_preparer"
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?(pyproject)
44
+ updated_files <<
45
+ updated_file(
46
+ file: pyproject,
47
+ content: updated_pyproject_content
48
+ )
49
+ end
50
+
51
+ if lockfile && lockfile.content == updated_lockfile_content
52
+ raise "Expected lockfile to change!"
53
+ end
54
+
55
+ if lockfile
56
+ updated_files <<
57
+ updated_file(file: lockfile, content: updated_lockfile_content)
58
+ end
59
+
60
+ updated_files
61
+ end
62
+
63
+ def updated_pyproject_content
64
+ dependencies.
65
+ select { |dep| requirement_changed?(pyproject, dep) }.
66
+ reduce(pyproject.content.dup) do |content, dep|
67
+ updated_requirement =
68
+ dep.requirements.find { |r| r[:file] == pyproject.name }.
69
+ fetch(:requirement)
70
+
71
+ old_req =
72
+ dep.previous_requirements.
73
+ find { |r| r[:file] == pyproject.name }.
74
+ fetch(:requirement)
75
+
76
+ updated_content =
77
+ content.gsub(declaration_regex(dep)) do |line|
78
+ line.gsub(old_req, updated_requirement)
79
+ end
80
+
81
+ raise "Content did not change!" if content == updated_content
82
+
83
+ updated_content
84
+ end
85
+ end
86
+
87
+ def updated_lockfile_content
88
+ @updated_lockfile_content ||=
89
+ begin
90
+ new_lockfile = updated_lockfile_content_for(prepared_pyproject)
91
+
92
+ tmp_hash =
93
+ TomlRB.parse(new_lockfile)["metadata"]["content-hash"]
94
+ correct_hash = pyproject_hash_for(updated_pyproject_content)
95
+
96
+ new_lockfile.gsub(tmp_hash, correct_hash)
97
+ end
98
+ end
99
+
100
+ def prepared_pyproject
101
+ content = updated_pyproject_content
102
+ content = freeze_other_dependencies(content)
103
+ content = freeze_dependencies_being_updated(content)
104
+ content = add_private_sources(content)
105
+ content = sanitize(content)
106
+ content
107
+ end
108
+
109
+ def freeze_other_dependencies(pyproject_content)
110
+ PyprojectPreparer.
111
+ new(pyproject_content: pyproject_content).
112
+ freeze_top_level_dependencies_except(dependencies, lockfile)
113
+ end
114
+
115
+ def freeze_dependencies_being_updated(pyproject_content)
116
+ pyproject_object = TomlRB.parse(pyproject_content)
117
+ poetry_object = pyproject_object.fetch("tool").fetch("poetry")
118
+
119
+ dependencies.each do |dep|
120
+ %w(dependencies dev-dependencies).each do |type|
121
+ names = poetry_object[type]&.keys || []
122
+ pkg_name = names.find { |nm| normalise(nm) == dep.name }
123
+ next unless pkg_name
124
+
125
+ if poetry_object[type][pkg_name].is_a?(Hash)
126
+ poetry_object[type][pkg_name]["version"] = dep.version
127
+ else
128
+ poetry_object[type][pkg_name] = dep.version
129
+ end
130
+ end
131
+ end
132
+
133
+ TomlRB.dump(pyproject_object)
134
+ end
135
+
136
+ def add_private_sources(pyproject_content)
137
+ PyprojectPreparer.
138
+ new(pyproject_content: pyproject_content).
139
+ replace_sources(credentials)
140
+ end
141
+
142
+ def sanitize(pyproject_content)
143
+ PyprojectPreparer.
144
+ new(pyproject_content: pyproject_content).
145
+ sanitize
146
+ end
147
+
148
+ def updated_lockfile_content_for(pyproject_content)
149
+ SharedHelpers.in_a_temporary_directory do
150
+ write_temporary_dependency_files(pyproject_content)
151
+
152
+ if python_version && !pre_installed_python?(python_version)
153
+ run_poetry_command("pyenv install -s")
154
+ run_poetry_command("pyenv exec pip install --upgrade pip")
155
+ run_poetry_command(
156
+ "pyenv exec pip install -r #{python_requirements_path}"
157
+ )
158
+ end
159
+
160
+ run_poetry_command("pyenv exec poetry lock")
161
+
162
+ return File.read("poetry.lock") if File.exist?("poetry.lock")
163
+
164
+ File.read("pyproject.lock")
165
+ end
166
+ end
167
+
168
+ def run_poetry_command(cmd)
169
+ raw_response = nil
170
+ IO.popen(cmd, err: %i(child out)) { |p| raw_response = p.read }
171
+
172
+ # Raise an error with the output from the shell session if Pipenv
173
+ # returns a non-zero status
174
+ return if $CHILD_STATUS.success?
175
+
176
+ raise SharedHelpers::HelperSubprocessFailed.new(raw_response, cmd)
177
+ end
178
+
179
+ def write_temporary_dependency_files(pyproject_content)
180
+ dependency_files.each do |file|
181
+ path = file.name
182
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
183
+ File.write(path, file.content)
184
+ end
185
+
186
+ # Overwrite the .python-version with updated content
187
+ File.write(".python-version", python_version) if python_version
188
+
189
+ # Overwrite the pyproject with updated content
190
+ File.write("pyproject.toml", pyproject_content)
191
+ end
192
+
193
+ def python_version
194
+ pyproject_object = TomlRB.parse(prepared_pyproject)
195
+ poetry_object = pyproject_object.dig("tool", "poetry")
196
+
197
+ requirement =
198
+ poetry_object&.dig("dependencies", "python") ||
199
+ poetry_object&.dig("dev-dependencies", "python")
200
+
201
+ return python_version_file&.content unless requirement
202
+
203
+ requirements = Python::Requirement.requirements_array(requirement)
204
+
205
+ PythonVersions::PYTHON_VERSIONS.find do |version|
206
+ requirements.any? do |r|
207
+ r.satisfied_by?(Python::Version.new(version))
208
+ end
209
+ end
210
+ end
211
+
212
+ def pre_installed_python?(version)
213
+ PythonVersions::PRE_INSTALLED_PYTHON_VERSIONS.include?(version)
214
+ end
215
+
216
+ def pyproject_hash_for(pyproject_content)
217
+ SharedHelpers.in_a_temporary_directory do |dir|
218
+ File.write(File.join(dir, "pyproject.toml"), pyproject_content)
219
+ SharedHelpers.run_helper_subprocess(
220
+ command: "pyenv exec python #{NativeHelpers.python_helper_path}",
221
+ function: "get_pyproject_hash",
222
+ args: [dir]
223
+ )
224
+ end
225
+ end
226
+
227
+ def declaration_regex(dep)
228
+ escaped_name = Regexp.escape(dep.name).gsub("\\-", "[-_.]")
229
+ /(?:^|["'])#{escaped_name}["']?\s*=.*$/i
230
+ end
231
+
232
+ def file_changed?(file)
233
+ dependencies.any? { |dep| requirement_changed?(file, dep) }
234
+ end
235
+
236
+ def requirement_changed?(file, dependency)
237
+ changed_requirements =
238
+ dependency.requirements - dependency.previous_requirements
239
+
240
+ changed_requirements.any? { |f| f[:file] == file.name }
241
+ end
242
+
243
+ def updated_file(file:, content:)
244
+ updated_file = file.dup
245
+ updated_file.content = content
246
+ updated_file
247
+ end
248
+
249
+ # See https://www.python.org/dev/peps/pep-0503/#normalized-names
250
+ def normalise(name)
251
+ name.downcase.gsub(/[-_.]+/, "-")
252
+ end
253
+
254
+ def pyproject
255
+ @pyproject ||=
256
+ dependency_files.find { |f| f.name == "pyproject.toml" }
257
+ end
258
+
259
+ def lockfile
260
+ @lockfile ||= pyproject_lock || poetry_lock
261
+ end
262
+
263
+ def pyproject_lock
264
+ dependency_files.find { |f| f.name == "pyproject.lock" }
265
+ end
266
+
267
+ def poetry_lock
268
+ dependency_files.find { |f| f.name == "poetry.lock" }
269
+ end
270
+
271
+ def python_version_file
272
+ dependency_files.find { |f| f.name == ".python-version" }
273
+ end
274
+
275
+ def python_requirements_path
276
+ project_root = File.join(File.dirname(__FILE__), "../../../../..")
277
+ File.join(project_root, "helpers/python/requirements.txt")
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,103 @@
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 PyprojectPreparer
12
+ def initialize(pyproject_content:)
13
+ @pyproject_content = pyproject_content
14
+ end
15
+
16
+ def replace_sources(credentials)
17
+ pyproject_object = TomlRB.parse(pyproject_content)
18
+ poetry_object = pyproject_object.fetch("tool").fetch("poetry")
19
+
20
+ poetry_object["source"] = pyproject_sources +
21
+ config_variable_sources(credentials)
22
+
23
+ TomlRB.dump(pyproject_object)
24
+ end
25
+
26
+ def sanitize
27
+ # {{ name }} syntax not allowed
28
+ pyproject_content.gsub(/\{\{.*?\}\}/, "something")
29
+ end
30
+
31
+ # rubocop:disable Metrics/PerceivedComplexity
32
+ def freeze_top_level_dependencies_except(dependencies, lockfile)
33
+ return pyproject_content unless lockfile
34
+
35
+ pyproject_object = TomlRB.parse(pyproject_content)
36
+ poetry_object = pyproject_object["tool"]["poetry"]
37
+ excluded_names = dependencies.map(&:name) + ["python"]
38
+
39
+ %w(dependencies dev-dependencies).each do |key|
40
+ next unless poetry_object[key]
41
+
42
+ poetry_object.fetch(key).each do |dep_name, _|
43
+ next if excluded_names.include?(normalise(dep_name))
44
+
45
+ locked_details = locked_details(dep_name, lockfile)
46
+
47
+ next unless (locked_version = locked_details&.fetch("version"))
48
+
49
+ if locked_details&.dig("source", "type") == "git"
50
+ poetry_object[key][dep_name] = {
51
+ "git" => locked_details&.dig("source", "url"),
52
+ "rev" => locked_details&.dig("source", "reference")
53
+ }
54
+ elsif poetry_object[dep_name].is_a?(Hash)
55
+ poetry_object[key][dep_name]["version"] = locked_version
56
+ else
57
+ poetry_object[key][dep_name] = locked_version
58
+ end
59
+ end
60
+ end
61
+
62
+ TomlRB.dump(pyproject_object)
63
+ end
64
+ # rubocop:enable Metrics/PerceivedComplexity
65
+
66
+ private
67
+
68
+ attr_reader :pyproject_content
69
+
70
+ def locked_details(dep_name, lockfile)
71
+ parsed_lockfile = TomlRB.parse(lockfile.content)
72
+
73
+ parsed_lockfile.fetch("package").
74
+ find { |d| d["name"] == normalise(dep_name) }
75
+ end
76
+
77
+ # See https://www.python.org/dev/peps/pep-0503/#normalized-names
78
+ def normalise(name)
79
+ name.downcase.gsub(/[-_.]+/, "-")
80
+ end
81
+
82
+ def pyproject_sources
83
+ return @pyproject_sources if @pyproject_sources
84
+
85
+ pyproject_sources ||=
86
+ TomlRB.parse(pyproject_content).
87
+ dig("tool", "poetry", "source")
88
+
89
+ @pyproject_sources ||=
90
+ (pyproject_sources || []).
91
+ map { |h| h.dup.merge("url" => h["url"].gsub(%r{/*$}, "") + "/") }
92
+ end
93
+
94
+ def config_variable_sources(credentials)
95
+ @config_variable_sources ||=
96
+ credentials.
97
+ select { |cred| cred["type"] == "python_index" }.
98
+ map { |cred| { "url" => cred["index-url"] } }
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/python/requirement_parser"
4
+ require "dependabot/python/file_updater"
5
+ require "dependabot/shared_helpers"
6
+ require "dependabot/python/native_helpers"
7
+
8
+ module Dependabot
9
+ module Python
10
+ class FileUpdater
11
+ class RequirementFileUpdater
12
+ attr_reader :dependencies, :dependency_files, :credentials
13
+
14
+ def initialize(dependencies:, dependency_files:, credentials:)
15
+ @dependencies = dependencies
16
+ @dependency_files = dependency_files
17
+ @credentials = credentials
18
+ end
19
+
20
+ def updated_dependency_files
21
+ return @updated_dependency_files if @update_already_attempted
22
+
23
+ @update_already_attempted = true
24
+ @updated_dependency_files ||= fetch_updated_dependency_files
25
+ end
26
+
27
+ private
28
+
29
+ def dependency
30
+ # For now, we'll only ever be updating a single dependency
31
+ dependencies.first
32
+ end
33
+
34
+ def fetch_updated_dependency_files
35
+ reqs = dependency.requirements.zip(dependency.previous_requirements)
36
+
37
+ reqs.map do |(new_req, old_req)|
38
+ next if new_req == old_req
39
+
40
+ file = get_original_file(new_req.fetch(:file)).dup
41
+ updated_content =
42
+ updated_requirement_or_setup_file_content(new_req, old_req)
43
+ next if updated_content == file.content
44
+
45
+ file.content = updated_content
46
+ file
47
+ end.compact
48
+ end
49
+
50
+ def updated_requirement_or_setup_file_content(new_req, old_req)
51
+ content = get_original_file(new_req.fetch(:file)).content
52
+
53
+ updated_content =
54
+ content.gsub(
55
+ original_declaration_replacement_regex(old_req),
56
+ updated_dependency_declaration_string(new_req, old_req)
57
+ )
58
+
59
+ raise "Expected content to change!" if content == updated_content
60
+
61
+ updated_content
62
+ end
63
+
64
+ def original_dependency_declaration_string(requirement)
65
+ regex = RequirementParser::INSTALL_REQ_WITH_REQUIREMENT
66
+ matches = []
67
+
68
+ get_original_file(requirement.fetch(:file)).
69
+ content.scan(regex) { matches << Regexp.last_match }
70
+ dec = matches.
71
+ select { |m| normalise(m[:name]) == dependency.name }.
72
+ find do |m|
73
+ # The FileParser can mess up a requirement's spacing so we
74
+ # sanitize both requirements before comparing
75
+ f_req = m[:requirements]&.gsub(/\s/, "")&.split(",")&.sort
76
+ p_req = requirement.fetch(:requirement)&.
77
+ gsub(/\s/, "")&.split(",")&.sort
78
+ f_req == p_req
79
+ end
80
+
81
+ raise "Declaration not found for #{dependency.name}!" unless dec
82
+
83
+ dec.to_s.strip
84
+ end
85
+
86
+ def updated_dependency_declaration_string(new_req, old_req)
87
+ updated_string =
88
+ original_dependency_declaration_string(old_req).sub(
89
+ RequirementParser::REQUIREMENTS,
90
+ new_req.fetch(:requirement)
91
+ )
92
+ return updated_string unless requirement_includes_hashes?(old_req)
93
+
94
+ updated_string.sub(
95
+ RequirementParser::HASHES,
96
+ package_hashes_for(
97
+ name: dependency.name,
98
+ version: dependency.version,
99
+ algorithm: hash_algorithm(old_req)
100
+ ).join(hash_separator(old_req))
101
+ )
102
+ end
103
+
104
+ def original_declaration_replacement_regex(requirement)
105
+ original_string =
106
+ original_dependency_declaration_string(requirement)
107
+ /(?<![\-\w])#{Regexp.escape(original_string)}(?![\-\w])/
108
+ end
109
+
110
+ def requirement_includes_hashes?(requirement)
111
+ original_dependency_declaration_string(requirement).
112
+ match?(RequirementParser::HASHES)
113
+ end
114
+
115
+ def hash_algorithm(requirement)
116
+ return unless requirement_includes_hashes?(requirement)
117
+
118
+ original_dependency_declaration_string(requirement).
119
+ match(RequirementParser::HASHES).
120
+ named_captures.fetch("algorithm")
121
+ end
122
+
123
+ def hash_separator(requirement)
124
+ return unless requirement_includes_hashes?(requirement)
125
+
126
+ hash_regex = RequirementParser::HASH
127
+ current_separator =
128
+ original_dependency_declaration_string(requirement).
129
+ match(/#{hash_regex}((?<separator>\s*\\?\s*?)#{hash_regex})*/).
130
+ named_captures.fetch("separator")
131
+
132
+ default_separator =
133
+ original_dependency_declaration_string(requirement).
134
+ match(RequirementParser::HASH).
135
+ pre_match.match(/(?<separator>\s*\\?\s*?)\z/).
136
+ named_captures.fetch("separator")
137
+
138
+ current_separator || default_separator
139
+ end
140
+
141
+ def package_hashes_for(name:, version:, algorithm:)
142
+ SharedHelpers.run_helper_subprocess(
143
+ command: "pyenv exec python #{NativeHelpers.python_helper_path}",
144
+ function: "get_dependency_hash",
145
+ args: [name, version, algorithm]
146
+ ).map { |h| "--hash=#{algorithm}:#{h['hash']}" }
147
+ end
148
+
149
+ # See https://www.python.org/dev/peps/pep-0503/#normalized-names
150
+ def normalise(name)
151
+ name.downcase.gsub(/[-_.]+/, "-")
152
+ end
153
+
154
+ def get_original_file(filename)
155
+ dependency_files.find { |f| f.name == filename }
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end