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