class Dependabot::Python::UpdateChecker::PoetryVersionResolver

This class does version resolution for pyproject.toml files.

Constants

GIT_DEPENDENCY_UNREACHABLE_REGEX
GIT_REFERENCE_NOT_FOUND_REGEX

Attributes

credentials[R]
dependency[R]
dependency_files[R]

Public Class Methods

new(dependency:, dependency_files:, credentials:) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 40
def initialize(dependency:, dependency_files:, credentials:)
  @dependency               = dependency
  @dependency_files         = dependency_files
  @credentials              = credentials
end

Public Instance Methods

latest_resolvable_version(requirement: nil) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 46
def latest_resolvable_version(requirement: nil)
  version_string =
    fetch_latest_resolvable_version_string(requirement: requirement)

  version_string.nil? ? nil : Python::Version.new(version_string)
end
resolvable?(version:) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 53
def resolvable?(version:)
  @resolvable ||= {}
  return @resolvable[version] if @resolvable.key?(version)

  @resolvable[version] = if fetch_latest_resolvable_version_string(requirement: "==#{version}")
                           true
                         else
                           false
                         end
rescue SharedHelpers::HelperSubprocessFailed => e
  raise unless e.message.include?("SolverProblemError")

  @resolvable[version] = false
end

Private Instance Methods

add_private_sources(pyproject_content) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 247
def add_private_sources(pyproject_content)
  Python::FileUpdater::PyprojectPreparer.
    new(pyproject_content: pyproject_content).
    replace_sources(credentials)
end
check_original_requirements_resolvable() click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 148
def check_original_requirements_resolvable
  return @original_reqs_resolvable if @original_reqs_resolvable

  SharedHelpers.in_a_temporary_directory do
    SharedHelpers.with_git_configured(credentials: credentials) do
      write_temporary_dependency_files(update_pyproject: false)

      run_poetry_command(poetry_update_command)

      @original_reqs_resolvable = true
    rescue SharedHelpers::HelperSubprocessFailed => e
      raise unless e.message.include?("SolverProblemError") ||
                   e.message.include?("PackageNotFound")

      msg = clean_error_message(e.message)
      raise DependencyFileNotResolvable, msg
    end
  end
end
clean_error_message(message) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 168
def clean_error_message(message)
  # Redact any URLs, as they may include credentials
  message.gsub(/http.*?(?=\s)/, "<redacted>")
end
fetch_latest_resolvable_version_string(requirement:) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 70
def fetch_latest_resolvable_version_string(requirement:)
  @latest_resolvable_version_string ||= {}
  return @latest_resolvable_version_string[requirement] if @latest_resolvable_version_string.key?(requirement)

  @latest_resolvable_version_string[requirement] ||=
    SharedHelpers.in_a_temporary_directory do
      SharedHelpers.with_git_configured(credentials: credentials) do
        write_temporary_dependency_files(updated_req: requirement)

        if python_version && !pre_installed_python?(python_version)
          run_poetry_command("pyenv install -s #{python_version}")
          run_poetry_command(
            "pyenv exec pip install -r "\
            "#{NativeHelpers.python_requirements_path}"
          )
        end

        # Shell out to Poetry, which handles everything for us.
        run_poetry_command(poetry_update_command)

        updated_lockfile =
          if File.exist?("poetry.lock") then File.read("poetry.lock")
          else File.read("pyproject.lock")
          end
        updated_lockfile = TomlRB.parse(updated_lockfile)

        fetch_version_from_parsed_lockfile(updated_lockfile)
      rescue SharedHelpers::HelperSubprocessFailed => e
        handle_poetry_errors(e)
      end
    end
end
fetch_version_from_parsed_lockfile(updated_lockfile) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 103
def fetch_version_from_parsed_lockfile(updated_lockfile)
  version =
    updated_lockfile.fetch("package", []).
    find { |d| d["name"] && normalise(d["name"]) == dependency.name }&.
    fetch("version")

  return version unless version.nil? && dependency.top_level?

  raise "No version in lockfile!"
end
freeze_other_dependencies(pyproject_content) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 253
def freeze_other_dependencies(pyproject_content)
  Python::FileUpdater::PyprojectPreparer.
    new(pyproject_content: pyproject_content, lockfile: lockfile).
    freeze_top_level_dependencies_except([dependency])
end
handle_poetry_errors(error) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 114
def handle_poetry_errors(error)
  if error.message.gsub(/\s/, "").match?(GIT_REFERENCE_NOT_FOUND_REGEX)
    message = error.message.gsub(/\s/, "")
    name = message.match(GIT_REFERENCE_NOT_FOUND_REGEX).
           named_captures.fetch("name")
    raise GitDependencyReferenceNotFound, name
  end

  if error.message.match?(GIT_DEPENDENCY_UNREACHABLE_REGEX)
    url = error.message.match(GIT_DEPENDENCY_UNREACHABLE_REGEX).
          named_captures.fetch("url")
    raise GitDependenciesNotReachable, url
  end

  raise unless error.message.include?("SolverProblemError") ||
               error.message.include?("PackageNotFound")

  check_original_requirements_resolvable

  # If the original requirements are resolvable but the new version
  # would break Python version compatibility the update is blocked
  return if error.message.include?("support the following Python")

  # If any kind of other error is now occurring as a result of our
  # change then we want to hear about it
  raise
end
lockfile() click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 309
def lockfile
  poetry_lock || pyproject_lock
end
normalise(name) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 333
def normalise(name)
  NameNormaliser.normalise(name)
end
poetry_lock() click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 305
def poetry_lock
  dependency_files.find { |f| f.name == "poetry.lock" }
end
poetry_update_command() click to toggle source

Using `–lock` avoids doing an install. Using `–no-interaction` avoids asking for passwords.

# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 144
def poetry_update_command
  "pyenv exec poetry update #{dependency.name} --lock --no-interaction"
end
pre_installed_python?(version) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 221
def pre_installed_python?(version)
  PythonVersions::PRE_INSTALLED_PYTHON_VERSIONS.include?(version)
end
pyproject() click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 297
def pyproject
  dependency_files.find { |f| f.name == "pyproject.toml" }
end
pyproject_lock() click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 301
def pyproject_lock
  dependency_files.find { |f| f.name == "pyproject.lock" }
end
python_requirement_parser() click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 214
def python_requirement_parser
  @python_requirement_parser ||=
    FileParser::PythonRequirementParser.new(
      dependency_files: dependency_files
    )
end
python_version() click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 195
def python_version
  requirements = python_requirement_parser.user_specified_requirements
  requirements = requirements.
                 map { |r| Python::Requirement.requirements_array(r) }

  version = PythonVersions::SUPPORTED_VERSIONS_TO_ITERATE.find do |v|
    requirements.all? do |reqs|
      reqs.any? { |r| r.satisfied_by?(Python::Version.new(v)) }
    end
  end
  return version if version

  msg = "Dependabot detected the following Python requirements "\
        "for your project: '#{requirements}'.\n\nCurrently, the "\
        "following Python versions are supported in Dependabot: "\
        "#{PythonVersions::SUPPORTED_VERSIONS.join(', ')}."
  raise DependencyFileNotResolvable, msg
end
run_poetry_command(command) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 313
def run_poetry_command(command)
  start = Time.now
  command = SharedHelpers.escape_command(command)
  stdout, process = Open3.capture2e(command)
  time_taken = Time.now - start

  # Raise an error with the output from the shell session if Pipenv
  # returns a non-zero status
  return if process.success?

  raise SharedHelpers::HelperSubprocessFailed.new(
    message: stdout,
    error_context: {
      command: command,
      time_taken: time_taken,
      process_exit_value: process.to_s
    }
  )
end
sanitize_pyproject_content(pyproject_content) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 241
def sanitize_pyproject_content(pyproject_content)
  Python::FileUpdater::PyprojectPreparer.
    new(pyproject_content: pyproject_content).
    sanitize
end
sanitized_pyproject_content() click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 234
def sanitized_pyproject_content
  content = pyproject.content
  content = sanitize_pyproject_content(content)
  content = add_private_sources(content)
  content
end
set_target_dependency_req(pyproject_content, updated_requirement) click to toggle source

rubocop:disable Metrics/PerceivedComplexity

# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 260
def set_target_dependency_req(pyproject_content, updated_requirement)
  return pyproject_content unless updated_requirement

  pyproject_object = TomlRB.parse(pyproject_content)
  poetry_object = pyproject_object.dig("tool", "poetry")

  Dependabot::Python::FileParser::PoetryFilesParser::POETRY_DEPENDENCY_TYPES.each do |type|
    names = poetry_object[type]&.keys || []
    pkg_name = names.find { |nm| normalise(nm) == dependency.name }
    next unless pkg_name

    if poetry_object.dig(type, pkg_name).is_a?(Hash)
      poetry_object[type][pkg_name]["version"] = updated_requirement
    else
      poetry_object[type][pkg_name] = updated_requirement
    end
  end

  # If this is a sub-dependency, add the new requirement
  unless dependency.requirements.find { |r| r[:file] == pyproject.name }
    poetry_object[subdep_type] ||= {}
    poetry_object[subdep_type][dependency.name] = updated_requirement
  end

  TomlRB.dump(pyproject_object)
end
subdep_type() click to toggle source

rubocop:enable Metrics/PerceivedComplexity

# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 288
def subdep_type
  category =
    TomlRB.parse(lockfile.content).fetch("package", []).
    find { |dets| normalise(dets.fetch("name")) == dependency.name }.
    fetch("category")

  category == "dev" ? "dev-dependencies" : "dependencies"
end
updated_pyproject_content(updated_requirement:) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 225
def updated_pyproject_content(updated_requirement:)
  content = pyproject.content
  content = sanitize_pyproject_content(content)
  content = add_private_sources(content)
  content = freeze_other_dependencies(content)
  content = set_target_dependency_req(content, updated_requirement)
  content
end
write_temporary_dependency_files(updated_req: nil, update_pyproject: true) click to toggle source
# File lib/dependabot/python/update_checker/poetry_version_resolver.rb, line 173
def write_temporary_dependency_files(updated_req: nil,
                                     update_pyproject: true)
  dependency_files.each do |file|
    path = file.name
    FileUtils.mkdir_p(Pathname.new(path).dirname)
    File.write(path, file.content)
  end

  # Overwrite the .python-version with updated content
  File.write(".python-version", python_version) if python_version

  # Overwrite the pyproject with updated content
  if update_pyproject
    File.write(
      "pyproject.toml",
      updated_pyproject_content(updated_requirement: updated_req)
    )
  else
    File.write("pyproject.toml", sanitized_pyproject_content)
  end
end