class Dependabot::Python::FileUpdater::PipfileFileUpdater

Attributes

credentials[R]
dependencies[R]
dependency_files[R]

Public Class Methods

new(dependencies:, dependency_files:, credentials:) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 23
def initialize(dependencies:, dependency_files:, credentials:)
  @dependencies = dependencies
  @dependency_files = dependency_files
  @credentials = credentials
end

Public Instance Methods

updated_dependency_files() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 29
def updated_dependency_files
  return @updated_dependency_files if @update_already_attempted

  @update_already_attempted = true
  @updated_dependency_files ||= fetch_updated_dependency_files
end

Private Instance Methods

add_private_sources(pipfile_content) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 179
def add_private_sources(pipfile_content)
  PipfilePreparer.
    new(pipfile_content: pipfile_content).
    replace_sources(credentials)
end
dependency() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 38
def dependency
  # For now, we'll only ever be updating a single dependency
  dependencies.first
end
fetch_updated_dependency_files() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 43
def fetch_updated_dependency_files
  updated_files = []

  if pipfile.content != updated_pipfile_content
    updated_files <<
      updated_file(file: pipfile, content: updated_pipfile_content)
  end

  if lockfile
    raise "Expected Pipfile.lock to change!" if lockfile.content == updated_lockfile_content

    updated_files <<
      updated_file(file: lockfile, content: updated_lockfile_content)
  end

  updated_files += updated_generated_requirements_files
  updated_files
end
freeze_dependencies_being_updated(pipfile_content) click to toggle source

rubocop:disable Metrics/PerceivedComplexity

# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 144
def freeze_dependencies_being_updated(pipfile_content)
  pipfile_object = TomlRB.parse(pipfile_content)

  dependencies.each do |dep|
    %w(packages dev-packages).each do |type|
      names = pipfile_object[type]&.keys || []
      pkg_name = names.find { |nm| normalise(nm) == dep.name }
      next unless pkg_name || subdep_type?(type)

      pkg_name ||= dependency.name
      if pipfile_object[type][pkg_name].is_a?(Hash)
        pipfile_object[type][pkg_name]["version"] =
          "==#{dep.version}"
      else
        pipfile_object[type][pkg_name] = "==#{dep.version}"
      end
    end
  end

  TomlRB.dump(pipfile_object)
end
freeze_other_dependencies(pipfile_content) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 137
def freeze_other_dependencies(pipfile_content)
  PipfilePreparer.
    new(pipfile_content: pipfile_content, lockfile: lockfile).
    freeze_top_level_dependencies_except(dependencies)
end
generate_updated_requirements_files() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 232
def generate_updated_requirements_files
  req_content = run_pipenv_command(
    "pyenv exec pipenv lock -r"
  )
  File.write("req.txt", req_content)

  dev_req_content = run_pipenv_command(
    "pyenv exec pipenv lock -r -d"
  )
  File.write("dev-req.txt", dev_req_content)
end
generate_updated_requirements_files?() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 75
def generate_updated_requirements_files?
  return true if generated_requirements_files("default").any?

  generated_requirements_files("develop").any?
end
generated_requirements_files(type) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 81
def generated_requirements_files(type)
  return [] unless lockfile

  pipfile_lock_deps = parsed_lockfile[type]&.keys&.sort || []
  pipfile_lock_deps = pipfile_lock_deps.map { |n| normalise(n) }
  return [] unless pipfile_lock_deps.any?

  regex = RequirementParser::INSTALL_REQ_WITH_REQUIREMENT

  # Find any requirement files that list the same dependencies as
  # the (old) Pipfile.lock. Any such files were almost certainly
  # generated using `pipenv lock -r`
  requirements_files.select do |req_file|
    deps = []
    req_file.content.scan(regex) { deps << Regexp.last_match }
    deps = deps.map { |m| normalise(m[:name]) }
    deps.sort == pipfile_lock_deps
  end
end
install_required_python() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 295
def install_required_python
  # Initialize a git repo to appease pip-tools
  begin
    run_command("git init") if setup_files.any?
  rescue Dependabot::SharedHelpers::HelperSubprocessFailed
    nil
  end

  return if run_command("pyenv versions").include?("#{python_version}\n")

  requirements_path = NativeHelpers.python_requirements_path
  run_command("pyenv install -s #{python_version}")
  run_command("pyenv exec pip install -r #{requirements_path}")
end
lockfile() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 405
def lockfile
  @lockfile ||= dependency_files.find { |f| f.name == "Pipfile.lock" }
end
normalise(name) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 393
def normalise(name)
  NameNormaliser.normalise(name)
end
parsed_lockfile() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 397
def parsed_lockfile
  @parsed_lockfile ||= JSON.parse(lockfile.content)
end
pipenv_env_variables() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 421
def pipenv_env_variables
  {
    "PIPENV_YES" => "true",       # Install new Python ver if needed
    "PIPENV_MAX_RETRIES" => "3",  # Retry timeouts
    "PIPENV_NOSPIN" => "1",       # Don't pollute logs with spinner
    "PIPENV_TIMEOUT" => "600",    # Set install timeout to 10 minutes
    "PIP_DEFAULT_TIMEOUT" => "60" # Set pip timeout to 1 minute
  }
end
pipfile() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 401
def pipfile
  @pipfile ||= dependency_files.find { |f| f.name == "Pipfile" }
end
pipfile_hash_for(pipfile_content) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 376
def pipfile_hash_for(pipfile_content)
  SharedHelpers.in_a_temporary_directory do |dir|
    File.write(File.join(dir, "Pipfile"), pipfile_content)
    SharedHelpers.run_helper_subprocess(
      command: "pyenv exec python #{NativeHelpers.python_helper_path}",
      function: "get_pipfile_hash",
      args: [dir]
    )
  end
end
post_process_lockfile(updated_lockfile_content) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 216
def post_process_lockfile(updated_lockfile_content)
  pipfile_hash = pipfile_hash_for(updated_pipfile_content)
  original_reqs = parsed_lockfile["_meta"]["requires"]
  original_source = parsed_lockfile["_meta"]["sources"]

  new_lockfile = updated_lockfile_content.dup
  new_lockfile_json = JSON.parse(new_lockfile)
  new_lockfile_json["_meta"]["hash"]["sha256"] = pipfile_hash
  new_lockfile_json["_meta"]["requires"] = original_reqs
  new_lockfile_json["_meta"]["sources"] = original_source

  JSON.pretty_generate(new_lockfile_json, indent: "    ").
    gsub(/\{\n\s*\}/, "{}").
    gsub(/\}\z/, "}\n")
end
prepared_pipfile_content() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 129
def prepared_pipfile_content
  content = updated_pipfile_content
  content = freeze_other_dependencies(content)
  content = freeze_dependencies_being_updated(content)
  content = add_private_sources(content)
  content
end
python_requirement_parser() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 363
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/file_updater/pipfile_file_updater.rb, line 320
def python_version
  @python_version ||= python_version_from_supported_versions
end
python_version_from_supported_versions() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 324
def python_version_from_supported_versions
  requirement_string =
    if @using_python_two then "2.7.*"
    elsif user_specified_python_requirement
      parts = user_specified_python_requirement.split(".")
      parts.fill("*", (parts.length)..2).join(".")
    else PythonVersions::PRE_INSTALLED_PYTHON_VERSIONS.first
    end

  # Ideally, the requirement is satisfied by a Python version we support
  requirement =
    Python::Requirement.requirements_array(requirement_string).first
  version =
    PythonVersions::SUPPORTED_VERSIONS_TO_ITERATE.
    find { |v| requirement.satisfied_by?(Python::Version.new(v)) }
  return version if version

  # If not, and changing the patch version would fix things, we do that
  # as the patch version is unlikely to affect resolution
  requirement =
    Python::Requirement.new(requirement_string.gsub(/\.\d+$/, ".*"))
  version =
    PythonVersions::SUPPORTED_VERSIONS_TO_ITERATE.
    find { |v| requirement.satisfied_by?(Python::Version.new(v)) }
  return version if version

  # Otherwise we have to raise, giving details of the Python versions
  # that Dependabot supports
  msg = "Dependabot detected the following Python requirement "\
        "for your project: '#{requirement_string}'.\n\nCurrently, the "\
        "following Python versions are supported in Dependabot: "\
        "#{PythonVersions::SUPPORTED_VERSIONS.join(', ')}."
  raise DependencyFileNotResolvable, msg
end
requirements_files() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 417
def requirements_files
  dependency_files.select { |f| f.name.end_with?(".txt") }
end
run_command(command, env: {}) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 244
def run_command(command, env: {})
  start = Time.now
  command = SharedHelpers.escape_command(command)
  stdout, process = Open3.capture2e(env, command)
  time_taken = Time.now - start

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

  raise SharedHelpers::HelperSubprocessFailed.new(
    message: stdout,
    error_context: {
      command: command,
      time_taken: time_taken,
      process_exit_value: process.to_s
    }
  )
end
run_pipenv_command(command, env: pipenv_env_variables) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 264
def run_pipenv_command(command, env: pipenv_env_variables)
  run_command("pyenv local #{python_version}")
  run_command(command, env: env)
end
sanitized_setup_file_content(file) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 310
def sanitized_setup_file_content(file)
  @sanitized_setup_file_content ||= {}
  return @sanitized_setup_file_content[file.name] if @sanitized_setup_file_content[file.name]

  @sanitized_setup_file_content[file.name] =
    SetupFileSanitizer.
    new(setup_file: file, setup_cfg: setup_cfg(file)).
    sanitized_content
end
setup_cfg(file) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 370
def setup_cfg(file)
  dependency_files.find do |f|
    f.name == file.name.sub(/\.py$/, ".cfg")
  end
end
setup_cfg_files() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 413
def setup_cfg_files
  dependency_files.select { |f| f.name.end_with?("setup.cfg") }
end
setup_files() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 409
def setup_files
  dependency_files.select { |f| f.name.end_with?("setup.py") }
end
subdep_type?(type) click to toggle source

rubocop:enable Metrics/PerceivedComplexity

# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 167
def subdep_type?(type)
  return false if dependency.top_level?

  lockfile_type = Python::FileParser::DEPENDENCY_GROUP_KEYS.
                  find { |i| i.fetch(:pipfile) == type }.
                  fetch(:lockfile)

  JSON.parse(lockfile.content).
    fetch(lockfile_type, {}).
    keys.any? { |k| normalise(k) == dependency.name }
end
updated_dev_req_content() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 125
def updated_dev_req_content
  updated_generated_files.fetch(:dev_requirements_txt)
end
updated_file(file:, content:) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 387
def updated_file(file:, content:)
  updated_file = file.dup
  updated_file.content = content
  updated_file
end
updated_generated_files() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 185
def updated_generated_files
  @updated_generated_files ||=
    SharedHelpers.in_a_temporary_directory do
      SharedHelpers.with_git_configured(credentials: credentials) do
        write_temporary_dependency_files(prepared_pipfile_content)
        install_required_python

        # Initialize a git repo to appease pip-tools
        command = SharedHelpers.escape_command("git init")
        IO.popen(command, err: %i(child out)) if setup_files.any?

        run_pipenv_command(
          "pyenv exec pipenv lock"
        )

        result = { lockfile: File.read("Pipfile.lock") }
        result[:lockfile] = post_process_lockfile(result[:lockfile])

        # Generate updated requirement.txt entries, if needed.
        if generate_updated_requirements_files?
          generate_updated_requirements_files

          result[:requirements_txt] = File.read("req.txt")
          result[:dev_requirements_txt] = File.read("dev-req.txt")
        end

        result
      end
    end
end
updated_generated_requirements_files() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 101
def updated_generated_requirements_files
  updated_files = []

  generated_requirements_files("default").each do |file|
    next if file.content == updated_req_content

    updated_files <<
      updated_file(file: file, content: updated_req_content)
  end

  generated_requirements_files("develop").each do |file|
    next if file.content == updated_dev_req_content

    updated_files <<
      updated_file(file: file, content: updated_dev_req_content)
  end

  updated_files
end
updated_lockfile_content() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 70
def updated_lockfile_content
  @updated_lockfile_content ||=
    updated_generated_files.fetch(:lockfile)
end
updated_pipfile_content() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 62
def updated_pipfile_content
  @updated_pipfile_content ||=
    PipfileManifestUpdater.new(
      dependencies: dependencies,
      manifest: pipfile
    ).updated_manifest_content
end
updated_req_content() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 121
def updated_req_content
  updated_generated_files.fetch(:requirements_txt)
end
user_specified_python_requirement() click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 359
def user_specified_python_requirement
  python_requirement_parser.user_specified_requirements.first
end
write_temporary_dependency_files(pipfile_content) click to toggle source
# File lib/dependabot/python/file_updater/pipfile_file_updater.rb, line 269
def write_temporary_dependency_files(pipfile_content)
  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)

  setup_files.each do |file|
    path = file.name
    FileUtils.mkdir_p(Pathname.new(path).dirname)
    File.write(path, sanitized_setup_file_content(file))
  end

  setup_cfg_files.each do |file|
    path = file.name
    FileUtils.mkdir_p(Pathname.new(path).dirname)
    File.write(path, "[metadata]\nname = sanitized-package\n")
  end

  # Overwrite the pipfile with updated content
  File.write("Pipfile", pipfile_content)
end