class Dependabot::Uv::FileParser::PyprojectFilesParser
def check_requirements(req)
def check_requirements(req) requirement = req.is_a?(String) ? req : req["version"] Uv::Requirement.requirements_array(requirement) rescue Gem::Requirement::BadRequirementError => e raise Dependabot::DependencyFileNotEvaluatable, e.message end
def dependency_set
def dependency_set dependency_set = Dependabot::FileParsers::Base::DependencySet.new dependency_set += pyproject_dependencies if using_poetry? || using_pep621? dependency_set += lockfile_dependencies if using_poetry? && lockfile dependency_set end
def initialize(dependency_files:)
def initialize(dependency_files:) @dependency_files = dependency_files end
def lockfile
def lockfile poetry_lock end
def lockfile_dependencies
def lockfile_dependencies dependencies = Dependabot::FileParsers::Base::DependencySet.new source_types = %w(directory git url) parsed_lockfile.fetch("package", []).each do |details| next if source_types.include?(details.dig("source", "type")) name = normalise(details.fetch("name")) dependencies << Dependency.new( name: name, version: details.fetch("version"), requirements: [], package_manager: "uv", subdependency_metadata: [{ production: production_dependency_names.include?(name) }] ) end dependencies end
def missing_poetry_keys
def missing_poetry_keys package_mode = T.must(poetry_root).fetch("package-mode", true) required_keys = package_mode ? %w(name version description authors) : [] required_keys.reject { |key| T.must(poetry_root).key?(key) } end
def normalise(name)
def normalise(name) NameNormaliser.normalise(name) end
def normalised_name(name, extras)
def normalised_name(name, extras) NameNormaliser.normalise_including_extras(name, extras) end
def parse_poetry_dependencies
def parse_poetry_dependencies dependencies = Dependabot::FileParsers::Base::DependencySet.new POETRY_DEPENDENCY_TYPES.each do |type| deps_hash = T.must(poetry_root)[type] || {} dependencies += parse_poetry_dependency_group(type, deps_hash) end groups = T.must(poetry_root)["group"] || {} groups.each do |group, group_spec| dependencies += parse_poetry_dependency_group(group, group_spec["dependencies"]) end dependencies end
def parse_poetry_dependency_group(type, deps_hash)
def parse_poetry_dependency_group(type, deps_hash) dependencies = Dependabot::FileParsers::Base::DependencySet.new deps_hash.each do |name, req| next if normalise(name) == "python" requirements = parse_requirements_from(req, type) next if requirements.empty? dependencies << Dependency.new( name: normalise(name), version: version_from_lockfile(name), requirements: requirements, package_manager: "uv" ) end dependencies end
def parse_production_dependency_names
def parse_production_dependency_names SharedHelpers.in_a_temporary_directory do File.write(T.must(pyproject).name, T.must(pyproject).content) File.write(lockfile.name, lockfile.content) begin output = SharedHelpers.run_shell_command("pyenv exec poetry show --only main") output.split("\n").map { |line| line.split.first } rescue SharedHelpers::HelperSubprocessFailed # Sometimes, we may be dealing with an old lockfile that our # poetry version can't show dependency information for. Other # commands we use like `poetry update` are more resilient and # automatically heal the lockfile. So we rescue the error and make # a best effort approach to this. poetry_dependencies.dependencies.filter_map do |dep| dep.name if dep.production? end end end end
def parse_requirements_from(req, type)
def parse_requirements_from(req, type) [req].flatten.compact.filter_map do |requirement| next if requirement.is_a?(Hash) && UNSUPPORTED_DEPENDENCY_TYPES.intersect?(requirement.keys) check_requirements(requirement) if requirement.is_a?(String) { requirement: requirement, file: T.must(pyproject).name, source: nil, groups: [type] } else { requirement: requirement["version"], file: T.must(pyproject).name, source: requirement.fetch("source", nil), groups: [type] } end end end
def parsed_lockfile
def parsed_lockfile parsed_poetry_lock if poetry_lock end
def parsed_pep621_dependencies
def parsed_pep621_dependencies SharedHelpers.in_a_temporary_directory do write_temporary_pyproject SharedHelpers.run_helper_subprocess( command: "pyenv exec python3 #{NativeHelpers.python_helper_path}", function: "parse_pep621_dependencies", args: [T.must(pyproject).name] ) end end
def parsed_poetry_lock
def parsed_poetry_lock @parsed_poetry_lock ||= T.let(TomlRB.parse(T.must(poetry_lock).content), T.untyped) rescue TomlRB::ParseError, TomlRB::ValueOverwriteError raise Dependabot::DependencyFileNotParseable, T.must(poetry_lock).path end
def parsed_pyproject
def parsed_pyproject @parsed_pyproject ||= T.let(TomlRB.parse(T.must(pyproject).content), T.untyped) rescue TomlRB::ParseError, TomlRB::ValueOverwriteError raise Dependabot::DependencyFileNotParseable, T.must(pyproject).path end
def pdm_lock
def pdm_lock @pdm_lock ||= T.let(dependency_files.find { |f| f.name == "pdm.lock" }, T.nilable(Dependabot::DependencyFile)) end
def pep621_dependencies
def pep621_dependencies dependencies = Dependabot::FileParsers::Base::DependencySet.new # PDM is not yet supported, so we want to ignore it for now because in # the current state of things, going on would result in updating # pyproject.toml but leaving pdm.lock out of sync, which is # undesirable. Leave PDM alone until properly supported return dependencies if using_pdm? parsed_pep621_dependencies.each do |dep| # If a requirement has a `<` or `<=` marker then updating it is # probably blocked. Ignore it. next if dep["markers"].include?("<") # If no requirement, don't add it next if dep["requirement"].empty? dependencies << Dependency.new( name: normalised_name(dep["name"], dep["extras"]), version: dep["version"]&.include?("*") ? nil : dep["version"], requirements: [{ requirement: dep["requirement"], file: Pathname.new(dep["file"]).cleanpath.to_path, source: nil, groups: [dep["requirement_type"]].compact }], package_manager: "uv" ) end dependencies end
def poetry_dependencies
def poetry_dependencies @poetry_dependencies ||= T.let(parse_poetry_dependencies, T.untyped) end
def poetry_lock
def poetry_lock @poetry_lock ||= T.let(dependency_files.find { |f| f.name == "poetry.lock" }, T.nilable(Dependabot::DependencyFile)) end
def poetry_root
def poetry_root parsed_pyproject.dig("tool", "poetry") end
def production_dependency_names
def production_dependency_names @production_dependency_names ||= T.let(parse_production_dependency_names, T.nilable(T::Array[T.nilable(String)])) end
def pyproject
def pyproject @pyproject ||= T.let(dependency_files.find { |f| f.name == "pyproject.toml" }, T.nilable(Dependabot::DependencyFile)) end
def pyproject_dependencies
def pyproject_dependencies if using_poetry? missing_keys = missing_poetry_keys if missing_keys.any? raise DependencyFileNotParseable.new( T.must(pyproject).path, "#{T.must(pyproject).path} is missing the following sections:\n" \ " * #{missing_keys.map { |key| "tool.poetry.#{key}" }.join("\n * ")}\n" ) end poetry_dependencies else pep621_dependencies end end
def using_pdm?
def using_pdm? using_pep621? && pdm_lock end
def using_pep621?
def using_pep621? !parsed_pyproject.dig("project", "dependencies").nil? || !parsed_pyproject.dig("project", "optional-dependencies").nil? || !parsed_pyproject.dig("build-system", "requires").nil? end
def using_poetry?
def using_poetry? !poetry_root.nil? end
def version_from_lockfile(dep_name)
def version_from_lockfile(dep_name) return unless parsed_lockfile parsed_lockfile.fetch("package", []) .find { |p| normalise(p.fetch("name")) == normalise(dep_name) } &.fetch("version", nil) end
def write_temporary_pyproject
def write_temporary_pyproject path = T.must(pyproject).name FileUtils.mkdir_p(Pathname.new(path).dirname) File.write(path, T.must(pyproject).content) end