# typed: true# frozen_string_literal: truerequire"toml-rb"require"open3"require"dependabot/dependency"require"dependabot/shared_helpers"require"dependabot/uv/language_version_manager"require"dependabot/uv/version"require"dependabot/uv/requirement"require"dependabot/uv/file_parser/python_requirement_parser"require"dependabot/uv/file_updater"require"dependabot/uv/native_helpers"require"dependabot/uv/name_normaliser"moduleDependabotmoduleUvclassFileUpdaterclassLockFileUpdaterrequire_relative"pyproject_preparer"attr_reader:dependenciesattr_reader:dependency_filesattr_reader:credentialsattr_reader:index_urlsdefinitialize(dependencies:,dependency_files:,credentials:,index_urls: nil)@dependencies=dependencies@dependency_files=dependency_files@credentials=credentials@index_urls=index_urlsenddefupdated_dependency_files@updated_dependency_files||=fetch_updated_dependency_filesendprivatedefdependency# For now, we'll only ever be updating a single dependencydependencies.firstenddeffetch_updated_dependency_filesupdated_files=[]iffile_changed?(pyproject)updated_files<<updated_file(file: pyproject,content: updated_pyproject_content)endiflockfile# Use updated_lockfile_content which might raise if the lockfile doesn't changenew_content=updated_lockfile_contentraise"Expected lockfile to change!"iflockfile.content==new_contentupdated_files<<updated_file(file: lockfile,content: new_content)endupdated_filesenddefupdated_pyproject_contentcontent=pyproject.contentreturncontentunlessfile_changed?(pyproject)updated_content=content.dupdependency.requirements.zip(dependency.previous_requirements).eachdo|new_r,old_r|nextunlessnew_r[:file]==pyproject.name&&old_r[:file]==pyproject.nameupdated_content=replace_dep(dependency,updated_content,new_r,old_r)endraiseDependencyFileContentNotChanged,"Content did not change!"ifcontent==updated_contentupdated_contentenddefreplace_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)ifdeclaration_matchdeclaration=declaration_match[:declaration]new_declaration=declaration.sub(old_req,new_req)content.sub(declaration,new_declaration)elsecontentendenddefupdated_lockfile_content@updated_lockfile_content||=beginoriginal_content=lockfile.content# Extract the original requires-python value to preserve itoriginal_requires_python=original_content.match(/requires-python\s*=\s*["']([^"']+)["']/)&.captures&.first# Use the original Python version requirement for the update if one existswith_original_python_version(original_requires_python)donew_lockfile=updated_lockfile_content_for(prepared_pyproject)# Use direct string replacement to preserve the exact format# Match the dependency section and update only the versiondependency_section_pattern=/
(\[\[package\]\]\s*\n
.*?name\s*=\s*["']#{Regexp.escape(dependency.name)}["']\s*\n
.*?)
(version\s*=\s*["'][^"']+["'])
(.*?)
(\[\[package\]\]|\z)
/xmresult=original_content.sub(dependency_section_pattern)dosection_start=Regexp.last_match(1)version_line="version = \"#{dependency.version}\""section_end=Regexp.last_match(3)next_section_or_end=Regexp.last_match(4)"#{section_start}#{version_line}#{section_end}#{next_section_or_end}"end# If the content didn't change and we expect it to, something went wrongifresult==original_contentDependabot.logger.warn("Package section not found for #{dependency.name}, falling back to raw update")result=new_lockfileend# Restore the original requires-python if it existsiforiginal_requires_pythonresult=result.gsub(/requires-python\s*=\s*["'][^"']+["']/,"requires-python = \"#{original_requires_python}\"")endresultendendend# Helper method to temporarily override Python version during operationsdefwith_original_python_version(original_requires_python)iforiginal_requires_pythonoriginal_python_version=@original_python_version@original_python_version=original_requires_pythonresult=yield@original_python_version=original_python_versionresultelseyieldendenddefprepared_pyproject@prepared_pyproject||=begincontent=updated_pyproject_contentcontent=sanitize(content)content=freeze_other_dependencies(content)content=update_python_requirement(content)contentendenddeffreeze_other_dependencies(pyproject_content)PyprojectPreparer.new(pyproject_content: pyproject_content,lockfile: lockfile).freeze_top_level_dependencies_except(dependencies)enddefupdate_python_requirement(pyproject_content)PyprojectPreparer.new(pyproject_content: pyproject_content).update_python_requirement(language_version_manager.python_version)enddefsanitize(pyproject_content)PyprojectPreparer.new(pyproject_content: pyproject_content).sanitizeenddefupdated_lockfile_content_for(pyproject_content)SharedHelpers.in_a_temporary_directorydoSharedHelpers.with_git_configured(credentials: credentials)dowrite_temporary_dependency_files(pyproject_content)# Install Python before writing .python-version to make sure we use a version that's availablelanguage_version_manager.install_required_python# Determine the Python version to use after installationpython_version=determine_python_version# Now write the .python-version file with a version we know is installedFile.write(".python-version",python_version)run_update_commandFile.read("uv.lock")endendenddefrun_update_commandcommand="pyenv exec uv lock --upgrade-package #{dependency.name}"fingerprint="pyenv exec uv lock --upgrade-package <dependency_name>"run_command(command,fingerprint:)enddefrun_command(command,fingerprint: nil)SharedHelpers.run_shell_command(command,fingerprint: fingerprint)enddefwrite_temporary_dependency_files(pyproject_content)dependency_files.eachdo|file|path=file.nameFileUtils.mkdir_p(Pathname.new(path).dirname)File.write(path,file.content)end# Only write the .python-version file after the language version manager has# installed the required Python version to ensure it's available# Overwrite the pyproject with updated contentFile.write("pyproject.toml",pyproject_content)enddefdetermine_python_version# Check available Python versions through pyenvavailable_versions=nilbeginavailable_versions=SharedHelpers.run_shell_command("pyenv versions --bare").split("\n").map(&:strip).reject(&:empty?)rescueStandardError=>eDependabot.logger.warn("Error checking available Python versions: #{e}")end# Try to find the closest match for our priority orderpreferred_version=find_preferred_version(available_versions)ifpreferred_version# Just return the major.minor version stringpreferred_version.match(/^(\d+\.\d+)/)[1]else# If all else fails, use "system" which should work with whatever Python is available"system"endenddeffind_preferred_version(available_versions)returnnilunlessavailable_versions&.any?# Try each strategy in order of preferencetry_version_from_file(available_versions)||try_version_from_requires_python(available_versions)||try_highest_python3_version(available_versions)enddeftry_version_from_file(available_versions)python_version_file=dependency_files.find{|f|f.name==".python-version"}returnnilunlesspython_version_file&&!python_version_file.content.strip.empty?requested_version=python_version_file.content.stripreturnrequested_versionifversion_available?(available_versions,requested_version)Dependabot.logger.info("Python version #{requested_version} from .python-version not available")nilenddeftry_version_from_requires_python(available_versions)returnnilunless@original_python_versionversion_match=@original_python_version.match(/(\d+\.\d+)/)returnnilunlessversion_matchrequested_version=version_match[1]returnrequested_versionifversion_available?(available_versions,requested_version)Dependabot.logger.info("Python version #{requested_version} from requires-python not available")nilenddeftry_highest_python3_version(available_versions)python3_versions=available_versions.select{|v|v.match(/^3\.\d+/)}.sort_by{|v|Gem::Version.new(v.match(/^(\d+\.\d+)/)[1])}.reversepython3_versions.first# returns nil if array is emptyenddefversion_available?(available_versions,requested_version)# Check if the exact version or a version with the same major.minor is availableavailable_versions.any?do|v|v==requested_version||v.start_with?("#{requested_version}.")endenddefsanitize_env_name(url)url.gsub(%r{^https?://},"").gsub(/[^a-zA-Z0-9]/,"_").upcaseenddefdeclaration_regex(dep,old_req)escaped_name=Regexp.escape(dep.name)# Extract the requirement operator and versionoperator=old_req.fetch(:requirement).match(/^(.+?)[0-9]/)&.captures&.first# Escape special regex characters in the operatorescaped_operator=Regexp.escape(operator)ifoperator# 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\.\*]+)["']?
/xenddefescape(name)Regexp.escape(name).gsub("\\-","[-_.]")enddeffile_changed?(file)returnfalseunlessfiledependencies.any?do|dep|dep.requirements.any?{|r|r[:file]==file.name}&&requirement_changed?(file,dep)endenddefrequirement_changed?(file,dependency)changed_requirements=dependency.requirements-dependency.previous_requirementschanged_requirements.any?{|f|f[:file]==file.name}enddefupdated_file(file:,content:)updated_file=file.dupupdated_file.content=contentupdated_fileenddefnormalise(name)NameNormaliser.normalise(name)enddefpython_requirement_parser@python_requirement_parser||=FileParser::PythonRequirementParser.new(dependency_files: dependency_files)enddeflanguage_version_manager@language_version_manager||=LanguageVersionManager.new(python_requirement_parser: python_requirement_parser)enddefpyproject@pyproject||=dependency_files.find{|f|f.name=="pyproject.toml"}enddeflockfile@lockfile||=uv_lockenddefpython_helper_pathNativeHelpers.python_helper_pathenddefuv_lockdependency_files.find{|f|f.name=="uv.lock"}endendendendend