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