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,298 @@
|
|
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/pyproject_preparer"
|
8
|
+
require "dependabot/python/update_checker"
|
9
|
+
require "dependabot/shared_helpers"
|
10
|
+
require "dependabot/python/version"
|
11
|
+
require "dependabot/python/requirement"
|
12
|
+
require "dependabot/errors"
|
13
|
+
require "dependabot/python/native_helpers"
|
14
|
+
require "dependabot/python/python_versions"
|
15
|
+
|
16
|
+
module Dependabot
|
17
|
+
module Python
|
18
|
+
class UpdateChecker
|
19
|
+
# This class does version resolution for pyproject.toml files.
|
20
|
+
class PoetryVersionResolver
|
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
|
+
|
33
|
+
check_private_sources_are_reachable
|
34
|
+
end
|
35
|
+
|
36
|
+
def latest_resolvable_version
|
37
|
+
return @latest_resolvable_version if @resolution_already_attempted
|
38
|
+
|
39
|
+
@resolution_already_attempted = true
|
40
|
+
@latest_resolvable_version ||= fetch_latest_resolvable_version
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
attr_reader :latest_allowable_version
|
46
|
+
|
47
|
+
def unlock_requirement?
|
48
|
+
@unlock_requirement
|
49
|
+
end
|
50
|
+
|
51
|
+
def fetch_latest_resolvable_version
|
52
|
+
@latest_resolvable_version_string ||=
|
53
|
+
SharedHelpers.in_a_temporary_directory do
|
54
|
+
write_temporary_dependency_files
|
55
|
+
|
56
|
+
if python_version && !pre_installed_python?(python_version)
|
57
|
+
run_poetry_command("pyenv install -s")
|
58
|
+
run_poetry_command(
|
59
|
+
"pyenv exec pip install -r #{python_requirements_path}"
|
60
|
+
)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Shell out to Poetry, which handles everything for us.
|
64
|
+
# Calling `lock` avoids doing an install.
|
65
|
+
run_poetry_command("pyenv exec poetry lock")
|
66
|
+
|
67
|
+
updated_lockfile =
|
68
|
+
if File.exist?("poetry.lock") then File.read("poetry.lock")
|
69
|
+
else File.read("pyproject.lock")
|
70
|
+
end
|
71
|
+
updated_lockfile = TomlRB.parse(updated_lockfile)
|
72
|
+
|
73
|
+
fetch_version_from_parsed_lockfile(updated_lockfile)
|
74
|
+
end
|
75
|
+
return unless @latest_resolvable_version_string
|
76
|
+
|
77
|
+
Python::Version.new(@latest_resolvable_version_string)
|
78
|
+
end
|
79
|
+
|
80
|
+
def fetch_version_from_parsed_lockfile(updated_lockfile)
|
81
|
+
updated_lockfile.fetch("package", []).
|
82
|
+
find { |d| d["name"] == dependency.name }.
|
83
|
+
fetch("version")
|
84
|
+
end
|
85
|
+
|
86
|
+
def write_temporary_dependency_files(update_pyproject: true)
|
87
|
+
dependency_files.each do |file|
|
88
|
+
path = file.name
|
89
|
+
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
90
|
+
File.write(path, file.content)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Overwrite the .python-version with updated content
|
94
|
+
File.write(".python-version", python_version) if python_version
|
95
|
+
|
96
|
+
# Overwrite the pyproject with updated content
|
97
|
+
if update_pyproject
|
98
|
+
File.write("pyproject.toml", updated_pyproject_content)
|
99
|
+
else
|
100
|
+
File.write("pyproject.toml", sanitized_pyproject_content)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def python_version
|
105
|
+
pyproject_object = TomlRB.parse(pyproject.content)
|
106
|
+
poetry_object = pyproject_object.dig("tool", "poetry")
|
107
|
+
|
108
|
+
requirement =
|
109
|
+
poetry_object&.dig("dependencies", "python") ||
|
110
|
+
poetry_object&.dig("dev-dependencies", "python")
|
111
|
+
|
112
|
+
return python_version_file&.content unless requirement
|
113
|
+
|
114
|
+
requirements =
|
115
|
+
Python::Requirement.requirements_array(requirement)
|
116
|
+
|
117
|
+
PythonVersions::PYTHON_VERSIONS.find do |version|
|
118
|
+
requirements.any? do |req|
|
119
|
+
req.satisfied_by?(Python::Version.new(version))
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def pre_installed_python?(version)
|
125
|
+
PythonVersions::PRE_INSTALLED_PYTHON_VERSIONS.include?(version)
|
126
|
+
end
|
127
|
+
|
128
|
+
def updated_pyproject_content
|
129
|
+
content = pyproject.content
|
130
|
+
content = sanitize_pyproject_content(content)
|
131
|
+
content = freeze_other_dependencies(content)
|
132
|
+
content = unlock_target_dependency(content) if unlock_requirement?
|
133
|
+
content
|
134
|
+
end
|
135
|
+
|
136
|
+
def sanitized_pyproject_content
|
137
|
+
content = pyproject.content
|
138
|
+
content = sanitize_pyproject_content(content)
|
139
|
+
content
|
140
|
+
end
|
141
|
+
|
142
|
+
def sanitize_pyproject_content(pyproject_content)
|
143
|
+
Python::FileUpdater::PyprojectPreparer.
|
144
|
+
new(pyproject_content: pyproject_content).
|
145
|
+
sanitize
|
146
|
+
end
|
147
|
+
|
148
|
+
def freeze_other_dependencies(pyproject_content)
|
149
|
+
Python::FileUpdater::PyprojectPreparer.
|
150
|
+
new(pyproject_content: pyproject_content).
|
151
|
+
freeze_top_level_dependencies_except([dependency], lockfile)
|
152
|
+
end
|
153
|
+
|
154
|
+
def unlock_target_dependency(pyproject_content)
|
155
|
+
pyproject_object = TomlRB.parse(pyproject_content)
|
156
|
+
poetry_object = pyproject_object.dig("tool", "poetry")
|
157
|
+
|
158
|
+
%w(dependencies dev-dependencies).each do |type|
|
159
|
+
names = poetry_object[type]&.keys || []
|
160
|
+
pkg_name = names.find { |nm| normalise(nm) == dependency.name }
|
161
|
+
next unless pkg_name
|
162
|
+
|
163
|
+
if poetry_object.dig(type, pkg_name).is_a?(Hash)
|
164
|
+
poetry_object[type][pkg_name]["version"] =
|
165
|
+
updated_version_requirement_string
|
166
|
+
else
|
167
|
+
poetry_object[type][pkg_name] =
|
168
|
+
updated_version_requirement_string
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
TomlRB.dump(pyproject_object)
|
173
|
+
end
|
174
|
+
|
175
|
+
def check_private_sources_are_reachable
|
176
|
+
sources_to_check =
|
177
|
+
pyproject_sources +
|
178
|
+
config_variable_sources
|
179
|
+
|
180
|
+
sources_to_check.
|
181
|
+
map { |details| details["url"] }.
|
182
|
+
reject { |url| MAIN_PYPI_INDEXES.include?(url) }.
|
183
|
+
each do |url|
|
184
|
+
sanitized_url = url.gsub(%r{(?<=//).*(?=@)}, "redacted")
|
185
|
+
|
186
|
+
response = Excon.get(
|
187
|
+
url,
|
188
|
+
idempotent: true,
|
189
|
+
**SharedHelpers.excon_defaults
|
190
|
+
)
|
191
|
+
|
192
|
+
if response.status == 401 || response.status == 403
|
193
|
+
raise PrivateSourceAuthenticationFailure, sanitized_url
|
194
|
+
end
|
195
|
+
rescue Excon::Error::Timeout, Excon::Error::Socket
|
196
|
+
raise PrivateSourceTimedOut, sanitized_url
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def updated_version_requirement_string
|
201
|
+
lower_bound_req = updated_version_req_lower_bound
|
202
|
+
|
203
|
+
# Add the latest_allowable_version as an upper bound. This means
|
204
|
+
# ignore conditions are considered when checking for the latest
|
205
|
+
# resolvable version.
|
206
|
+
#
|
207
|
+
# NOTE: This isn't perfect. If v2.x is ignored and v3 is out but
|
208
|
+
# unresolvable then the `latest_allowable_version` will be v3, and
|
209
|
+
# we won't be ignoring v2.x releases like we should be.
|
210
|
+
return lower_bound_req if latest_allowable_version.nil?
|
211
|
+
unless Python::Version.correct?(latest_allowable_version)
|
212
|
+
return lower_bound_req
|
213
|
+
end
|
214
|
+
|
215
|
+
lower_bound_req + ", <= #{latest_allowable_version}"
|
216
|
+
end
|
217
|
+
|
218
|
+
def updated_version_req_lower_bound
|
219
|
+
if dependency.version
|
220
|
+
">= #{dependency.version}"
|
221
|
+
else
|
222
|
+
version_for_requirement =
|
223
|
+
dependency.requirements.map { |r| r[:requirement] }.compact.
|
224
|
+
reject { |req_string| req_string.start_with?("<") }.
|
225
|
+
select { |req_string| req_string.match?(VERSION_REGEX) }.
|
226
|
+
map { |req_string| req_string.match(VERSION_REGEX) }.
|
227
|
+
select { |version| Gem::Version.correct?(version) }.
|
228
|
+
max_by { |version| Gem::Version.new(version) }
|
229
|
+
|
230
|
+
">= #{version_for_requirement || 0}"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
def pyproject
|
235
|
+
dependency_files.find { |f| f.name == "pyproject.toml" }
|
236
|
+
end
|
237
|
+
|
238
|
+
def pyproject_lock
|
239
|
+
dependency_files.find { |f| f.name == "pyproject.lock" }
|
240
|
+
end
|
241
|
+
|
242
|
+
def poetry_lock
|
243
|
+
dependency_files.find { |f| f.name == "poetry.lock" }
|
244
|
+
end
|
245
|
+
|
246
|
+
def lockfile
|
247
|
+
poetry_lock || pyproject_lock
|
248
|
+
end
|
249
|
+
|
250
|
+
def python_version_file
|
251
|
+
dependency_files.find { |f| f.name == ".python-version" }
|
252
|
+
end
|
253
|
+
|
254
|
+
def run_poetry_command(command)
|
255
|
+
raw_response = nil
|
256
|
+
IO.popen(command, err: %i(child out)) do |process|
|
257
|
+
raw_response = process.read
|
258
|
+
end
|
259
|
+
|
260
|
+
# Raise an error with the output from the shell session if Pipenv
|
261
|
+
# returns a non-zero status
|
262
|
+
return if $CHILD_STATUS.success?
|
263
|
+
|
264
|
+
raise SharedHelpers::HelperSubprocessFailed.new(
|
265
|
+
raw_response,
|
266
|
+
command
|
267
|
+
)
|
268
|
+
end
|
269
|
+
|
270
|
+
# See https://www.python.org/dev/peps/pep-0503/#normalized-names
|
271
|
+
def normalise(name)
|
272
|
+
name.downcase.gsub(/[-_.]+/, "-")
|
273
|
+
end
|
274
|
+
|
275
|
+
def config_variable_sources
|
276
|
+
@config_variable_sources ||=
|
277
|
+
credentials.
|
278
|
+
select { |cred| cred["type"] == "python_index" }.
|
279
|
+
map { |h| { "url" => h["index-url"].gsub(%r{/*$}, "") + "/" } }
|
280
|
+
end
|
281
|
+
|
282
|
+
def pyproject_sources
|
283
|
+
sources =
|
284
|
+
TomlRB.parse(pyproject.content).dig("tool", "poetry", "source") ||
|
285
|
+
[]
|
286
|
+
|
287
|
+
@pyproject_sources ||=
|
288
|
+
sources.
|
289
|
+
map { |h| h.dup.merge("url" => h["url"].gsub(%r{/*$}, "") + "/") }
|
290
|
+
end
|
291
|
+
|
292
|
+
def python_requirements_path
|
293
|
+
File.join(NativeHelpers.python_helper_path, "requirements.txt")
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
@@ -0,0 +1,365 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dependabot/python/requirement_parser"
|
4
|
+
require "dependabot/python/update_checker"
|
5
|
+
require "dependabot/python/version"
|
6
|
+
require "dependabot/python/requirement"
|
7
|
+
|
8
|
+
# rubocop:disable Metrics/ClassLength
|
9
|
+
module Dependabot
|
10
|
+
module Python
|
11
|
+
class UpdateChecker
|
12
|
+
class RequirementsUpdater
|
13
|
+
PYPROJECT_OR_SEPARATOR = /(?<=[a-zA-Z0-9*])\s*\|+/.freeze
|
14
|
+
PYPROJECT_SEPARATOR = /#{PYPROJECT_OR_SEPARATOR}|,/.freeze
|
15
|
+
|
16
|
+
class UnfixableRequirement < StandardError; end
|
17
|
+
|
18
|
+
attr_reader :requirements, :update_strategy, :has_lockfile,
|
19
|
+
:latest_version, :latest_resolvable_version
|
20
|
+
|
21
|
+
def initialize(requirements:, update_strategy:, has_lockfile:,
|
22
|
+
latest_version:, latest_resolvable_version:)
|
23
|
+
@requirements = requirements
|
24
|
+
@update_strategy = update_strategy
|
25
|
+
@has_lockfile = has_lockfile
|
26
|
+
|
27
|
+
if latest_version
|
28
|
+
@latest_version = Python::Version.new(latest_version)
|
29
|
+
end
|
30
|
+
|
31
|
+
return unless latest_resolvable_version
|
32
|
+
|
33
|
+
@latest_resolvable_version =
|
34
|
+
Python::Version.new(latest_resolvable_version)
|
35
|
+
end
|
36
|
+
|
37
|
+
def updated_requirements
|
38
|
+
requirements.map do |req|
|
39
|
+
case req[:file]
|
40
|
+
when "setup.py" then updated_setup_requirement(req)
|
41
|
+
when "pyproject.toml" then updated_pyproject_requirement(req)
|
42
|
+
when "Pipfile" then updated_pipfile_requirement(req)
|
43
|
+
when /\.txt$|\.in$/ then updated_requirement(req)
|
44
|
+
else raise "Unexpected filename: #{req[:file]}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
52
|
+
def updated_setup_requirement(req)
|
53
|
+
return req unless latest_resolvable_version
|
54
|
+
return req unless req.fetch(:requirement)
|
55
|
+
return req if new_version_satisfies?(req)
|
56
|
+
|
57
|
+
req_strings = req[:requirement].split(",").map(&:strip)
|
58
|
+
|
59
|
+
new_requirement =
|
60
|
+
if req_strings.any? { |r| requirement_class.new(r).exact? }
|
61
|
+
find_and_update_equality_match(req_strings)
|
62
|
+
elsif req_strings.any? { |r| r.start_with?("~=", "==") }
|
63
|
+
tw_req = req_strings.find { |r| r.start_with?("~=", "==") }
|
64
|
+
convert_to_range(tw_req, latest_resolvable_version)
|
65
|
+
else
|
66
|
+
update_requirements_range(req_strings)
|
67
|
+
end
|
68
|
+
|
69
|
+
req.merge(requirement: new_requirement)
|
70
|
+
rescue UnfixableRequirement
|
71
|
+
req.merge(requirement: :unfixable)
|
72
|
+
end
|
73
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
74
|
+
|
75
|
+
def updated_pipfile_requirement(req)
|
76
|
+
# For now, we just proxy to updated_requirement. In future this
|
77
|
+
# method may treat Pipfile requirements differently.
|
78
|
+
updated_requirement(req)
|
79
|
+
end
|
80
|
+
|
81
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
82
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
83
|
+
def updated_pyproject_requirement(req)
|
84
|
+
return req unless latest_resolvable_version
|
85
|
+
return req unless req.fetch(:requirement)
|
86
|
+
return req if new_version_satisfies?(req) && !has_lockfile
|
87
|
+
|
88
|
+
# If the requirement uses || syntax then we always want to widen it
|
89
|
+
if req.fetch(:requirement).match?(PYPROJECT_OR_SEPARATOR)
|
90
|
+
return widen_pyproject_requirement(req)
|
91
|
+
end
|
92
|
+
|
93
|
+
# If the requirement is a development dependency we always want to
|
94
|
+
# bump it
|
95
|
+
if req.fetch(:groups).include?("dev-dependencies")
|
96
|
+
return update_pyproject_version(req)
|
97
|
+
end
|
98
|
+
|
99
|
+
case update_strategy
|
100
|
+
when :widen_ranges then widen_pyproject_requirement(req)
|
101
|
+
when :bump_versions then update_pyproject_version(req)
|
102
|
+
else raise "Unexpected update strategy: #{update_strategy}"
|
103
|
+
end
|
104
|
+
rescue UnfixableRequirement
|
105
|
+
req.merge(requirement: :unfixable)
|
106
|
+
end
|
107
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
108
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
109
|
+
|
110
|
+
def update_pyproject_version(req)
|
111
|
+
requirement_strings = req[:requirement].split(",").map(&:strip)
|
112
|
+
|
113
|
+
new_requirement =
|
114
|
+
if requirement_strings.any? { |r| r.match?(/^=|^\d/) }
|
115
|
+
# If there is an equality operator, just update that. It must
|
116
|
+
# be binding and any other requirements will be being ignored
|
117
|
+
find_and_update_equality_match(requirement_strings)
|
118
|
+
elsif requirement_strings.any? { |r| r.start_with?("~", "^") }
|
119
|
+
# If a compatibility operator is being used, just bump its
|
120
|
+
# version (and remove any other requirements)
|
121
|
+
v_req = requirement_strings.find { |r| r.start_with?("~", "^") }
|
122
|
+
bump_version(v_req, latest_resolvable_version.to_s)
|
123
|
+
elsif new_version_satisfies?(req)
|
124
|
+
# Otherwise we're looking at a range operator. No change
|
125
|
+
# required if it's already satisfied
|
126
|
+
req.fetch(:requirement)
|
127
|
+
else
|
128
|
+
# But if it's not, update it
|
129
|
+
update_requirements_range(requirement_strings)
|
130
|
+
end
|
131
|
+
|
132
|
+
req.merge(requirement: new_requirement)
|
133
|
+
end
|
134
|
+
|
135
|
+
def widen_pyproject_requirement(req)
|
136
|
+
return req if new_version_satisfies?(req)
|
137
|
+
|
138
|
+
new_requirement =
|
139
|
+
if req[:requirement].match?(PYPROJECT_OR_SEPARATOR)
|
140
|
+
add_new_requirement_option(req[:requirement])
|
141
|
+
else
|
142
|
+
widen_requirement_range(req[:requirement])
|
143
|
+
end
|
144
|
+
|
145
|
+
req.merge(requirement: new_requirement)
|
146
|
+
end
|
147
|
+
|
148
|
+
def add_new_requirement_option(req_string)
|
149
|
+
option_to_copy = req_string.split(PYPROJECT_OR_SEPARATOR).last.
|
150
|
+
split(PYPROJECT_SEPARATOR).first.strip
|
151
|
+
operator = option_to_copy.gsub(/\d.*/, "").strip
|
152
|
+
|
153
|
+
new_option =
|
154
|
+
case operator
|
155
|
+
when "", "==", "==="
|
156
|
+
find_and_update_equality_match([option_to_copy])
|
157
|
+
when "~=", "~", "^"
|
158
|
+
bump_version(option_to_copy, latest_resolvable_version.to_s)
|
159
|
+
else
|
160
|
+
# We don't expect to see OR conditions used with range
|
161
|
+
# operators. If / when we see it, we should handle it.
|
162
|
+
raise "Unexpected operator: #{operator}"
|
163
|
+
end
|
164
|
+
|
165
|
+
# TODO: Match source spacing
|
166
|
+
"#{req_string.strip} || #{new_option.strip}"
|
167
|
+
end
|
168
|
+
|
169
|
+
def widen_requirement_range(req_string)
|
170
|
+
requirement_strings = req_string.split(",").map(&:strip)
|
171
|
+
|
172
|
+
if requirement_strings.any? { |r| r.match?(/(^=|^\d)[^*]*$/) }
|
173
|
+
# If there is an equality operator, just update that.
|
174
|
+
# (i.e., assume it's being used deliberately)
|
175
|
+
find_and_update_equality_match(requirement_strings)
|
176
|
+
elsif requirement_strings.any? { |r| r.start_with?("~", "^") } ||
|
177
|
+
requirement_strings.any? { |r| r.include?("*") }
|
178
|
+
# If a compatibility operator is being used, widen its
|
179
|
+
# range to include the new version
|
180
|
+
v_req = requirement_strings.
|
181
|
+
find { |r| r.start_with?("~", "^") || r.include?("*") }
|
182
|
+
convert_to_range(v_req, latest_resolvable_version)
|
183
|
+
else
|
184
|
+
# Otherwise we have a range, and need to update the upper bound
|
185
|
+
update_requirements_range(requirement_strings)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
190
|
+
def updated_requirement(req)
|
191
|
+
return req unless latest_resolvable_version
|
192
|
+
return req unless req.fetch(:requirement)
|
193
|
+
|
194
|
+
requirement_strings = req[:requirement].split(",").map(&:strip)
|
195
|
+
|
196
|
+
new_requirement =
|
197
|
+
if requirement_strings.any? { |r| r.match?(/^[=\d]/) }
|
198
|
+
find_and_update_equality_match(requirement_strings)
|
199
|
+
elsif requirement_strings.any? { |r| r.start_with?("~=") }
|
200
|
+
tw_req = requirement_strings.find { |r| r.start_with?("~=") }
|
201
|
+
bump_version(tw_req, latest_resolvable_version.to_s)
|
202
|
+
elsif new_version_satisfies?(req)
|
203
|
+
req.fetch(:requirement)
|
204
|
+
else
|
205
|
+
update_requirements_range(requirement_strings)
|
206
|
+
end
|
207
|
+
req.merge(requirement: new_requirement)
|
208
|
+
rescue UnfixableRequirement
|
209
|
+
req.merge(requirement: :unfixable)
|
210
|
+
end
|
211
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
212
|
+
|
213
|
+
def new_version_satisfies?(req)
|
214
|
+
requirement_class.
|
215
|
+
requirements_array(req.fetch(:requirement)).
|
216
|
+
any? { |r| r.satisfied_by?(latest_resolvable_version) }
|
217
|
+
end
|
218
|
+
|
219
|
+
def find_and_update_equality_match(requirement_strings)
|
220
|
+
if requirement_strings.any? { |r| requirement_class.new(r).exact? }
|
221
|
+
# True equality match
|
222
|
+
requirement_strings.find { |r| requirement_class.new(r).exact? }.
|
223
|
+
sub(
|
224
|
+
RequirementParser::VERSION,
|
225
|
+
latest_resolvable_version.to_s
|
226
|
+
)
|
227
|
+
else
|
228
|
+
# Prefix match
|
229
|
+
requirement_strings.find { |r| r.match?(/^(=+|\d)/) }.
|
230
|
+
sub(RequirementParser::VERSION) do |v|
|
231
|
+
at_same_precision(latest_resolvable_version.to_s, v)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
def at_same_precision(new_version, old_version)
|
237
|
+
# return new_version unless old_version.include?("*")
|
238
|
+
|
239
|
+
count = old_version.split(".").count
|
240
|
+
precision = old_version.split(".").index("*") || count
|
241
|
+
|
242
|
+
new_version.
|
243
|
+
split(".").
|
244
|
+
first(count).
|
245
|
+
map.with_index { |s, i| i < precision ? s : "*" }.
|
246
|
+
join(".")
|
247
|
+
end
|
248
|
+
|
249
|
+
def update_requirements_range(requirement_strings)
|
250
|
+
ruby_requirements =
|
251
|
+
requirement_strings.map { |r| requirement_class.new(r) }
|
252
|
+
|
253
|
+
updated_requirement_strings = ruby_requirements.flat_map do |r|
|
254
|
+
next r.to_s if r.satisfied_by?(latest_resolvable_version)
|
255
|
+
|
256
|
+
case op = r.requirements.first.first
|
257
|
+
when "<", "<="
|
258
|
+
"<" + update_greatest_version(r.to_s, latest_resolvable_version)
|
259
|
+
when "!="
|
260
|
+
nil
|
261
|
+
when ">", ">="
|
262
|
+
raise UnfixableRequirement
|
263
|
+
else
|
264
|
+
raise "Unexpected op for unsatisfied requirement: #{op}"
|
265
|
+
end
|
266
|
+
end.compact
|
267
|
+
|
268
|
+
updated_requirement_strings.
|
269
|
+
sort_by { |r| requirement_class.new(r).requirements.first.last }.
|
270
|
+
map(&:to_s).join(",").delete(" ")
|
271
|
+
end
|
272
|
+
|
273
|
+
# Updates the version in a constraint to be the given version
|
274
|
+
def bump_version(req_string, version_to_be_permitted)
|
275
|
+
old_version = req_string.
|
276
|
+
match(/(#{RequirementParser::VERSION})/).
|
277
|
+
captures.first
|
278
|
+
|
279
|
+
req_string.sub(
|
280
|
+
old_version,
|
281
|
+
at_same_precision(version_to_be_permitted, old_version)
|
282
|
+
)
|
283
|
+
end
|
284
|
+
|
285
|
+
def convert_to_range(req_string, version_to_be_permitted)
|
286
|
+
# Construct an upper bound at the same precision that the original
|
287
|
+
# requirement was at (taking into account ~ dynamics)
|
288
|
+
index_to_update = index_to_update_for(req_string)
|
289
|
+
ub_segments = version_to_be_permitted.segments
|
290
|
+
ub_segments << 0 while ub_segments.count <= index_to_update
|
291
|
+
ub_segments = ub_segments[0..index_to_update]
|
292
|
+
ub_segments[index_to_update] += 1
|
293
|
+
|
294
|
+
lb_segments = lower_bound_segments_for_req(req_string)
|
295
|
+
|
296
|
+
# Ensure versions have the same length as each other (cosmetic)
|
297
|
+
length = [lb_segments.count, ub_segments.count].max
|
298
|
+
lb_segments.fill(0, lb_segments.count...length)
|
299
|
+
ub_segments.fill(0, ub_segments.count...length)
|
300
|
+
|
301
|
+
">=#{lb_segments.join('.')},<#{ub_segments.join('.')}"
|
302
|
+
end
|
303
|
+
|
304
|
+
def lower_bound_segments_for_req(req_string)
|
305
|
+
requirement = requirement_class.new(req_string)
|
306
|
+
version = requirement.requirements.first.last
|
307
|
+
version = version.release if version.prerelease?
|
308
|
+
|
309
|
+
lb_segments = version.segments
|
310
|
+
lb_segments.pop while lb_segments.last.zero?
|
311
|
+
|
312
|
+
lb_segments
|
313
|
+
end
|
314
|
+
|
315
|
+
def index_to_update_for(req_string)
|
316
|
+
req = requirement_class.new(req_string.split(/[.\-]\*/).first)
|
317
|
+
version = req.requirements.first.last.release
|
318
|
+
|
319
|
+
if req_string.strip.start_with?("^")
|
320
|
+
version.segments.index { |i| i != 0 }
|
321
|
+
elsif req_string.include?("*")
|
322
|
+
version.segments.count - 1
|
323
|
+
elsif req_string.strip.start_with?("~=", "==")
|
324
|
+
version.segments.count - 2
|
325
|
+
elsif req_string.strip.start_with?("~")
|
326
|
+
req_string.split(".").count == 1 ? 0 : 1
|
327
|
+
else raise "Don't know how to convert #{req_string} to range"
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
# Updates the version in a "<" or "<=" constraint to allow the given
|
332
|
+
# version
|
333
|
+
def update_greatest_version(req_string, version_to_be_permitted)
|
334
|
+
if version_to_be_permitted.is_a?(String)
|
335
|
+
version_to_be_permitted =
|
336
|
+
Python::Version.new(version_to_be_permitted)
|
337
|
+
end
|
338
|
+
version = Python::Version.new(req_string.gsub(/<=?/, ""))
|
339
|
+
version = version.release if version.prerelease?
|
340
|
+
|
341
|
+
index_to_update = [
|
342
|
+
version.segments.map.with_index { |n, i| n.zero? ? 0 : i }.max,
|
343
|
+
version_to_be_permitted.segments.count - 1
|
344
|
+
].min
|
345
|
+
|
346
|
+
new_segments = version.segments.map.with_index do |_, index|
|
347
|
+
if index < index_to_update
|
348
|
+
version_to_be_permitted.segments[index]
|
349
|
+
elsif index == index_to_update
|
350
|
+
version_to_be_permitted.segments[index] + 1
|
351
|
+
else 0
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
new_segments.join(".")
|
356
|
+
end
|
357
|
+
|
358
|
+
def requirement_class
|
359
|
+
Python::Requirement
|
360
|
+
end
|
361
|
+
end
|
362
|
+
end
|
363
|
+
end
|
364
|
+
end
|
365
|
+
# rubocop:enable Metrics/ClassLength
|