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