class Dependabot::Uv::FileUpdater::LockFileUpdater
def create_or_update_lock_file?
def create_or_update_lock_file? T.must(dependency).requirements.select { _1[:file].end_with?(*REQUIRED_FILES) }.any? end
def declaration_regex(dep, old_req)
def declaration_regex(dep, old_req) escaped_name = Regexp.escape(dep.name) # Extract the requirement operator and version operator = old_req.fetch(:requirement).match(/^(.+?)[0-9]/)&.captures&.first # Escape special regex characters in the operator escaped_operator = Regexp.escape(operator) if operator # Match various formats of dependency declarations: # 1. "dependency==1.0.0" (with quotes around the entire string) # 2. dependency==1.0.0 (without quotes) # The declaration should only include the package name, operator, and version # without the enclosing quotes / ["']?(?<declaration>#{escaped_name}\s*#{escaped_operator}[\d\.\*]+)["']? /x end
def dependency
def dependency # For now, we'll only ever be updating a single dependency T.must(dependencies.first) end
def escape(name)
def escape(name) Regexp.escape(name).gsub("\\-", "[-_.]") end
def fetch_updated_dependency_files
def fetch_updated_dependency_files return [] unless create_or_update_lock_file? updated_files = [] if file_changed?(pyproject) updated_files << updated_file( file: T.must(pyproject), content: T.must(updated_pyproject_content) ) end if lockfile # Use updated_lockfile_content which might raise if the lockfile doesn't change new_content = updated_lockfile_content raise "Expected lockfile to change!" if T.must(lockfile).content == new_content updated_files << updated_file(file: T.must(lockfile), content: new_content) end updated_files end
def file_changed?(file)
def file_changed?(file) return false unless file dependencies.any? do |dep| dep.requirements.any? { |r| r[:file] == file.name } && requirement_changed?(file, dep) end end
def initialize(dependencies:, dependency_files:, credentials:, index_urls: nil)
def initialize(dependencies:, dependency_files:, credentials:, index_urls: nil) @dependencies = dependencies @dependency_files = dependency_files @credentials = credentials @index_urls = index_urls @prepared_pyproject = T.let(nil, T.nilable(String)) @updated_lockfile_content = T.let(nil, T.nilable(String)) @pyproject = T.let(nil, T.nilable(Dependabot::DependencyFile)) 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 lock_index_options
def lock_index_options credentials .select { |cred| cred["type"] == "python_index" } .map do |cred| authed_url = AuthedUrlBuilder.authed_url(credential: cred) if cred.replaces_base? "--default-index #{authed_url}" else "--index #{authed_url}" end end end
def lock_options
def lock_options options = lock_index_options options.join(" ") end
def lock_options_fingerprint(options)
def lock_options_fingerprint(options) options.sub( /--default-index\s+\S+/, "--default-index <default_index>" ).sub( /--index\s+\S+/, "--index <index>" ) end
def lockfile
def lockfile @lockfile ||= T.let(uv_lock, T.nilable(Dependabot::DependencyFile)) end
def normalise(name)
def normalise(name) NameNormaliser.normalise(name) end
def normalize_line_endings(content, reference)
def normalize_line_endings(content, reference) # Check if reference has escaped newlines like "\n" + if reference.include?("\\n") content.gsub("\n", "\\n") else content end end
def prepared_pyproject
def prepared_pyproject @prepared_pyproject ||= begin content = updated_pyproject_content content = sanitize(T.must(content)) content end end
def pyproject
def pyproject @pyproject ||= T.let(dependency_files.find { |f| f.name == "pyproject.toml" }, T.nilable(Dependabot::DependencyFile)) end
def python_helper_path
def python_helper_path NativeHelpers.python_helper_path end
def python_requirement_parser
def python_requirement_parser @python_requirement_parser ||= T.let( FileParser::PythonRequirementParser.new( dependency_files: dependency_files ), T.nilable(FileParser::PythonRequirementParser) ) end
def replace_dep(dep, content, new_r, old_r)
def replace_dep(dep, content, new_r, old_r) new_req = new_r[:requirement] old_req = old_r[:requirement] declaration_regex = declaration_regex(dep, old_r) declaration_match = content.match(declaration_regex) if declaration_match declaration = declaration_match[:declaration] new_declaration = T.must(declaration).sub(old_req, new_req) content.sub(T.must(declaration), new_declaration) else content end end
def requirement_changed?(file, dependency)
def requirement_changed?(file, dependency) changed_requirements = dependency.requirements - T.must(dependency.previous_requirements) changed_requirements.any? { |f| f[:file] == T.must(file).name } end
def run_command(command, fingerprint: nil)
def run_command(command, fingerprint: nil) Dependabot.logger.info("Running command: #{command}") SharedHelpers.run_shell_command(command, fingerprint: fingerprint) end
def run_update_command
def run_update_command options = lock_options options_fingerprint = lock_options_fingerprint(options) # Use pyenv exec to ensure we're using the correct Python environment command = "pyenv exec uv lock --upgrade-package #{T.must(dependency).name} #{options}" fingerprint = "pyenv exec uv lock --upgrade-package <dependency_name> #{options_fingerprint}" run_command(command, fingerprint:) end
def sanitize(pyproject_content)
def sanitize(pyproject_content) PyprojectPreparer .new(pyproject_content: pyproject_content) .sanitize end
def sanitize_env_name(url)
def sanitize_env_name(url) url.gsub(%r{^https?://}, "").gsub(/[^a-zA-Z0-9]/, "_").upcase end
def setup_python_environment
def setup_python_environment # Use LanguageVersionManager to determine and install the appropriate Python version Dependabot.logger.info("Setting up Python environment using LanguageVersionManager") begin # Install the required Python version language_version_manager.install_required_python # Set the local Python version python_version = language_version_manager.python_version Dependabot.logger.info("Setting Python version to #{python_version}") SharedHelpers.run_shell_command("pyenv local #{python_version}") # We don't need to install uv as it should be available in the Docker environment Dependabot.logger.info("Using pre-installed uv package") rescue StandardError => e Dependabot.logger.warn("Error setting up Python environment: #{e.message}") Dependabot.logger.info("Falling back to system Python") end end
def updated_dependency_files
def updated_dependency_files @updated_dependency_files ||= T.let(fetch_updated_dependency_files, T.nilable(T::Array[Dependabot::DependencyFile])) end
def updated_file(file:, content:)
def updated_file(file:, content:) updated_file = file.dup updated_file.content = content updated_file end
def updated_lockfile_content
def updated_lockfile_content @updated_lockfile_content ||= begin original_content = T.must(lockfile).content # Extract the original requires-python value to preserve it original_requires_python = T.must(original_content) .match(/requires-python\s*=\s*["']([^"']+)["']/)&.captures&.first # Store the original Python version requirement for later use @original_python_version = T.let(original_requires_python, T.nilable(String)) new_lockfile = updated_lockfile_content_for(prepared_pyproject) # Normalize line endings to ensure proper comparison new_lockfile = normalize_line_endings(new_lockfile, T.must(original_content)) result = new_lockfile # Restore the original requires-python if it exists if original_requires_python result = result.gsub(/requires-python\s*=\s*["'][^"']+["']/, "requires-python = \"#{original_requires_python}\"") end result end end
def updated_lockfile_content_for(pyproject_content)
def updated_lockfile_content_for(pyproject_content) SharedHelpers.in_a_temporary_directory do SharedHelpers.with_git_configured(credentials: credentials) do write_temporary_dependency_files(pyproject_content) # Set up Python environment using LanguageVersionManager setup_python_environment run_update_command File.read("uv.lock") end end end
def updated_pyproject_content
def updated_pyproject_content content = T.must(pyproject).content return content unless file_changed?(T.must(pyproject)) updated_content = content.dup T.must(dependency).requirements.zip(T.must(T.must(dependency).previous_requirements)).each do |new_r, old_r| next unless new_r[:file] == T.must(pyproject).name && T.must(old_r)[:file] == T.must(pyproject).name updated_content = replace_dep(T.must(dependency), T.must(updated_content), new_r, T.must(old_r)) end raise DependencyFileContentNotChanged, "Content did not change!" if content == updated_content updated_content end
def uv_lock
def uv_lock dependency_files.find { |f| f.name == "uv.lock" } end
def write_temporary_dependency_files(pyproject_content)
def write_temporary_dependency_files(pyproject_content) dependency_files.each do |file| path = file.name FileUtils.mkdir_p(Pathname.new(path).dirname) File.write(path, file.content) end # Overwrite the pyproject with updated content File.write("pyproject.toml", pyproject_content) end