lib/dependabot/uv/file_parser/python_requirement_parser.rb



# typed: true
# frozen_string_literal: true

require "toml-rb"
require "open3"
require "dependabot/errors"
require "dependabot/shared_helpers"
require "dependabot/uv/file_parser"
require "dependabot/uv/requirements_file_matcher"
require "dependabot/uv/requirement"

module Dependabot
  module Uv
    class FileParser
      class PythonRequirementParser
        attr_reader :dependency_files

        def initialize(dependency_files:)
          @dependency_files = dependency_files
        end

        def user_specified_requirements
          [
            pyproject_python_requirement,
            pip_compile_python_requirement,
            python_version_file_version,
            runtime_file_python_version
          ].compact
        end

        # TODO: Add better Python version detection using dependency versions
        # (e.g., Django 2.x implies Python 3)
        def imputed_requirements
          requirement_files.flat_map do |file|
            file.content.lines
                .select { |l| l.include?(";") && l.include?("python") }
                .filter_map { |l| l.match(/python_version(?<req>.*?["'].*?['"])/) }
                .map { |re| re.named_captures.fetch("req").gsub(/['"]/, "") }
                .select { |r| valid_requirement?(r) }
          end
        end

        private

        def pyproject_python_requirement
          return unless pyproject

          pyproject_object = TomlRB.parse(pyproject.content)

          # Check for PEP621 requires-python
          pep621_python = pyproject_object.dig("project", "requires-python")
          return pep621_python if pep621_python

          # Fallback to Poetry configuration
          poetry_object = pyproject_object.dig("tool", "poetry")

          poetry_object&.dig("dependencies", "python") ||
            poetry_object&.dig("dev-dependencies", "python")
        end

        def pip_compile_python_requirement
          requirement_files.each do |file|
            next unless requirements_in_file_matcher.compiled_file?(file)

            marker = /^# This file is autogenerated by pip-compile with [pP]ython (?<version>\d+.\d+)$/m
            match = marker.match(file.content)
            next unless match

            return match[:version]
          end

          nil
        end

        def python_version_file_version
          return unless python_version_file

          # read the content, split into lines and remove any lines with '#'
          content_lines = python_version_file.content.each_line.map do |line|
            line.sub(/#.*$/, " ").strip
          end.reject(&:empty?)

          file_version = content_lines.first
          return if file_version&.empty?
          return unless pyenv_versions.include?("#{file_version}\n")

          file_version
        end

        def runtime_file_python_version
          return unless runtime_file

          file_version = runtime_file.content
                                     .match(/(?<=python-).*/)&.to_s&.strip
          return if file_version&.empty?
          return unless pyenv_versions.include?("#{file_version}\n")

          file_version
        end

        def pyenv_versions
          @pyenv_versions ||= run_command("pyenv install --list")
        end

        def run_command(command, env: {})
          SharedHelpers.run_shell_command(command, env: env, stderr_to_stdout: true)
        end

        def requirements_in_file_matcher
          @requirements_in_file_matcher ||= RequiremenstFileMatcher.new(pip_compile_files)
        end

        def requirement_class
          Dependabot::Uv::Requirement
        end

        def valid_requirement?(req_string)
          requirement_class.new(req_string)
          true
        rescue Gem::Requirement::BadRequirementError
          false
        end

        def pyproject
          dependency_files.find { |f| f.name == "pyproject.toml" }
        end

        def python_version_file
          dependency_files.find { |f| f.name == ".python-version" }
        end

        def runtime_file
          dependency_files.find { |f| f.name.end_with?("runtime.txt") }
        end

        def requirement_files
          dependency_files.select { |f| f.name.end_with?(".txt") }
        end

        def pip_compile_files
          dependency_files.select { |f| f.name.end_with?(".in") }
        end
      end
    end
  end
end