class Dependabot::Uv::FileParser
def self.normalize_dependency_name(name, extras = [])
def self.normalize_dependency_name(name, extras = []) NameNormaliser.normalise_including_extras(name, extras) end
def blocking_marker?(dep)
def blocking_marker?(dep) return false if dep["markers"] == "None" marker = dep["markers"] version = python_raw_version if marker.include?("python_version") !marker_satisfied?(marker, version) else return true if dep["markers"].include?("<") return false if dep["markers"].include?(">") return false if dep["requirement"].nil? dep["requirement"].include?("<") end end
def check_required_files
def check_required_files filenames = dependency_files.map(&:name) return if filenames.any? { |name| name.end_with?(".txt", ".in") } return if pyproject raise "Missing required files!" end
def check_requirements(requirements)
def check_requirements(requirements) requirements.each do |dep| next unless dep["requirement"] Requirement.new(dep["requirement"].split(",")) rescue Gem::Requirement::BadRequirementError => e raise DependencyFileNotEvaluatable, e.message end end
def detect_uv_version
def detect_uv_version version = uv_version.to_s.split("version ").last&.split(")")&.first log_if_version_malformed("uv", version) version if version&.match?(/^\d+(?:\.\d+)*$/) rescue StandardError nil end
def detected_package_manager
def detected_package_manager PackageManager.new(T.must(detect_uv_version)) end
def ecosystem
def ecosystem @ecosystem ||= T.let( Ecosystem.new( name: ECOSYSTEM, package_manager: package_manager, language: language ), T.nilable(Ecosystem) ) end
def evaluate_condition(condition, python_version)
def evaluate_condition(condition, python_version) operator, version = condition.match(/([<>=!]=?)\s*"?([\d.]+)"?/)&.captures case operator when "<" Version.new(python_version) < Version.new(version) when "<=" Version.new(python_version) <= Version.new(version) when ">" Version.new(python_version) > Version.new(version) when ">=" Version.new(python_version) >= Version.new(version) when "==" Version.new(python_version) == Version.new(version) else false end end
def group_from_filename(filename)
def group_from_filename(filename) if filename.include?("dev") then ["dev-dependencies"] else ["dependencies"] end end
def language
def language Language.new( detected_version: python_raw_version, raw_version: python_command_version ) end
def language_version_manager
def language_version_manager @language_version_manager ||= T.let(LanguageVersionManager.new(python_requirement_parser: python_requirement_parser), T.nilable(LanguageVersionManager)) end
def log_if_version_malformed(package_manager, version)
def log_if_version_malformed(package_manager, version) if version.match?(/^\d+(?:\.\d+)*$/) true else Dependabot.logger.warn("Detected #{package_manager} with malformed version #{version}") false end end
def marker_satisfied?(marker, python_version)
def marker_satisfied?(marker, python_version) conditions = marker.split(/\s+(and|or)\s+/) result = T.let(evaluate_condition(conditions.shift, python_version), T::Boolean) until conditions.empty? operator = conditions.shift next_condition = conditions.shift next_result = evaluate_condition(next_condition, python_version) result = if operator == "and" result && next_result else result || next_result end end result end
def normalised_name(name, extras = [])
def normalised_name(name, extras = []) FileParser.normalize_dependency_name(name, extras) end
def old_pyyaml?(name, version)
def old_pyyaml?(name, version) major_version = version&.split(".")&.first return false unless major_version name == "pyyaml" && major_version < "6" end
def package_manager
def package_manager @package_manager ||= T.let(detected_package_manager, T.nilable(Ecosystem::VersionManager)) end
def parse
def parse dependency_set = DependencySet.new dependency_set += pyproject_file_dependencies if pyproject dependency_set += uv_lock_file_dependencies dependency_set += requirement_dependencies if requirement_files.any? dependency_set.dependencies end
def parsed_requirement_files
def parsed_requirement_files SharedHelpers.in_a_temporary_directory do write_temporary_dependency_files requirements = SharedHelpers.run_helper_subprocess( command: "pyenv exec python3 #{NativeHelpers.python_helper_path}", function: "parse_requirements", args: [Dir.pwd] ) check_requirements(requirements) requirements end rescue SharedHelpers::HelperSubprocessFailed => e evaluation_errors = REQUIREMENT_FILE_EVALUATION_ERRORS raise unless e.message.start_with?(*evaluation_errors) raise DependencyFileNotEvaluatable, e.message end
def pyproject
def pyproject @pyproject ||= T.let(get_original_file("pyproject.toml"), T.nilable(DependencyFile)) end
def pyproject_file_dependencies
def pyproject_file_dependencies @pyproject_file_dependencies ||= T.let(PyprojectFilesParser.new(dependency_files: dependency_files).dependency_set, T.nilable(DependencySet)) end
def python_command_version
def python_command_version language_version_manager.installed_version end
def python_raw_version
def python_raw_version language_version_manager.python_version end
def python_requirement_parser
def python_requirement_parser @python_requirement_parser ||= T.let(PythonRequirementParser.new(dependency_files: dependency_files), T.nilable(PythonRequirementParser)) end
def remove_imports(file)
def remove_imports(file) return file.content if file.path.end_with?(".tar.gz", ".whl", ".zip") file.content.lines .reject { |l| l.match?(/^['"]?(?<path>\..*?)(?=\[|#|'|"|$)/) } .reject { |l| l.match?(/^(?:-e)\s+['"]?(?<path>.*?)(?=\[|#|'|"|$)/) } .join end
def requirement_dependencies
def requirement_dependencies dependencies = DependencySet.new parsed_requirement_files.each do |dep| next if blocking_marker?(dep) name = dep["name"] file = dep["file"] version = dep["version"] original_file = get_original_file(file) requirements = if original_file && requirements_in_file_matcher.compiled_file?(original_file) then [] else [{ requirement: dep["requirement"], file: Pathname.new(file).cleanpath.to_path, source: nil, groups: group_from_filename(file) }] end # PyYAML < 6.0 will cause `pip-compile` to fail due to incompatibility with Cython 3. Workaround it. PR #8189 SharedHelpers.run_shell_command("pyenv exec pip install cython<3.0") if old_pyyaml?(name, version) dependencies << Dependency.new( name: normalised_name(name, dep["extras"]), version: version&.include?("*") ? nil : version, requirements: requirements, package_manager: "uv" ) end dependencies end
def requirement_files
def requirement_files dependency_files.select { |f| f.name.end_with?(".txt", ".in") } end
def requirements_in_file_matcher
def requirements_in_file_matcher @requirements_in_file_matcher ||= T.let(RequiremenstFileMatcher.new(requirements_in_files), T.nilable(RequiremenstFileMatcher)) end
def requirements_in_files
def requirements_in_files @requirements_in_files ||= T.let(dependency_files.select { |f| f.name.end_with?(".in") }, T.untyped) end
def setup_python_environment
def setup_python_environment language_version_manager.install_required_python SharedHelpers.run_shell_command("pyenv local #{language_version_manager.python_major_minor}") rescue StandardError => e Dependabot.logger.error(e.message) nil end
def uv_lock_file_dependencies
def uv_lock_file_dependencies dependency_set = DependencySet.new uv_lock_files.each do |file| lockfile_content = TomlRB.parse(file.content) packages = lockfile_content.fetch("package", []) packages.each do |package_data| next unless package_data.is_a?(Hash) && package_data["name"] && package_data["version"] dependency_set << Dependency.new( name: normalised_name(package_data["name"]), version: package_data["version"], requirements: [], # Lock files don't contain requirements package_manager: "uv" ) end rescue StandardError => e Dependabot.logger.warn("Error parsing uv.lock: #{e.message}") end dependency_set end
def uv_lock_files
def uv_lock_files dependency_files.select { |f| f.name == "uv.lock" } end
def uv_version
def uv_version version_info = SharedHelpers.run_shell_command("pyenv exec uv --version") Dependabot.logger.info("Package manager uv, Info : #{version_info}") version_info.match(/\d+(?:\.\d+)*/)&.to_s rescue StandardError => e Dependabot.logger.error(e.message) nil end
def write_temporary_dependency_files
def write_temporary_dependency_files dependency_files .reject { |f| f.name == ".python-version" } .each do |file| path = file.name FileUtils.mkdir_p(Pathname.new(path).dirname) File.write(path, remove_imports(file)) end end