# typed: true# frozen_string_literal: truerequire"open3"require"dependabot/dependency"require"dependabot/uv/requirement_parser"require"dependabot/uv/file_fetcher"require"dependabot/uv/file_parser"require"dependabot/uv/file_parser/python_requirement_parser"require"dependabot/uv/update_checker"require"dependabot/uv/file_updater/requirement_replacer"require"dependabot/uv/version"require"dependabot/shared_helpers"require"dependabot/uv/language_version_manager"require"dependabot/uv/native_helpers"require"dependabot/uv/name_normaliser"require"dependabot/uv/authed_url_builder"moduleDependabotmoduleUvclassUpdateChecker# This class does version resolution for pip-compile. Its approach is:# - Unlock the dependency we're checking in the requirements.in file# - Run `pip-compile` and see what the result isclassPipCompileVersionResolverGIT_DEPENDENCY_UNREACHABLE_REGEX=/git clone --filter=blob:none --quiet (?<url>[^\s]+).* /GIT_REFERENCE_NOT_FOUND_REGEX=/Did not find branch or tag '(?<tag>[^\n"]+)'/mNATIVE_COMPILATION_ERROR="pip._internal.exceptions.InstallationSubprocessError: Getting requirements to build wheel exited with 1"# See https://packaging.python.org/en/latest/tutorials/packaging-projects/#configuring-metadataPYTHON_PACKAGE_NAME_REGEX=/[A-Za-z0-9_\-]+/RESOLUTION_IMPOSSIBLE_ERROR="ResolutionImpossible"ERROR_REGEX=/(?<=ERROR\:\W).*$/UV_UNRESOLVABLE_REGEX=/ × No solution found when resolving dependencies:[\s\S]*$/attr_reader:dependencyattr_reader:dependency_filesattr_reader:credentialsattr_reader:repo_contents_pathattr_reader:error_handlerdefinitialize(dependency:,dependency_files:,credentials:,repo_contents_path:)@dependency=dependency@dependency_files=dependency_files@credentials=credentials@repo_contents_path=repo_contents_path@build_isolation=true@error_handler=PipCompileErrorHandler.newenddeflatest_resolvable_version(requirement: nil)@latest_resolvable_version_string||={}return@latest_resolvable_version_string[requirement]if@latest_resolvable_version_string.key?(requirement)version_string=fetch_latest_resolvable_version_string(requirement: requirement)@latest_resolvable_version_string[requirement]||=version_string.nil??nil:Uv::Version.new(version_string)enddefresolvable?(version:)@resolvable||={}return@resolvable[version]if@resolvable.key?(version)@resolvable[version]=iflatest_resolvable_version(requirement: "==#{version}")trueelsefalseendendprivatedeffetch_latest_resolvable_version_string(requirement:)SharedHelpers.in_a_temporary_directorydoSharedHelpers.with_git_configured(credentials: credentials)dowrite_temporary_dependency_files(updated_req: requirement)language_version_manager.install_required_pythonfilenames_to_compile.eachdo|filename|returnnilunlesscompile_file(filename)end# Remove any .python-version file before parsing the reqsFileUtils.remove_entry(".python-version",true)parse_updated_filesendendenddefcompile_file(filename)# Shell out to pip-compile.# This is slow, as pip-compile needs to do installs.options=pip_compile_options(filename)options_fingerprint=pip_compile_options_fingerprint(options)run_pip_compile_command("pyenv exec uv pip compile -v #{options} -P #{dependency.name}#{filename}",fingerprint: "pyenv exec uv pip compile -v #{options_fingerprint} -P <dependency_name> <filename>")returntrueifdependency.top_level?# Run pip-compile a second time for transient dependencies# to make sure we do not update dependencies that are# superfluous. pip-compile does not detect these when# updating a specific dependency with the -P option.# Running pip-compile a second time will automatically remove# superfluous dependencies. Dependabot then marks those with# update_not_possible.write_original_manifest_filesrun_pip_compile_command("pyenv exec uv pip compile #{options}#{filename}",fingerprint: "pyenv exec uv pip compile #{options_fingerprint} <filename>")truerescueSharedHelpers::HelperSubprocessFailed=>eretry_count||=0retry_count+=1ifcompilation_error?(e)&&retry_count<=1@build_isolation=falseretryendhandle_pip_compile_errors(e.message)enddefcompilation_error?(error)error.message.include?(NATIVE_COMPILATION_ERROR)end# rubocop:disable Metrics/AbcSize# rubocop:disable Metrics/PerceivedComplexitydefhandle_pip_compile_errors(message)ifmessage.include?("No solution found when resolving dependencies")raiseDependencyFileNotResolvable,message.scan(UV_UNRESOLVABLE_REGEX).lastendcheck_original_requirements_resolvableifmessage.include?(RESOLUTION_IMPOSSIBLE_ERROR)# If there's an unsupported constraint, check if it existed# previously (and raise if it did)check_original_requirements_resolvableifmessage.include?("UnsupportedConstraint")ifmessage.include?(RESOLUTION_IMPOSSIBLE_ERROR)&&!message.match?(/#{Regexp.quote(dependency.name)}/i)# Sometimes pip-tools gets confused and can't work around# sub-dependency incompatibilities. Ignore those cases.returnnilendifmessage.match?(GIT_REFERENCE_NOT_FOUND_REGEX)tag=message.match(GIT_REFERENCE_NOT_FOUND_REGEX).named_captures.fetch("tag")constraints_section=message.split("Finding the best candidates:").firstegg_regex=/#{Regexp.escape(tag)}#egg=(#{PYTHON_PACKAGE_NAME_REGEX})/name_match=constraints_section.scan(egg_regex)# We can determine the name of the package from another part of the logger output if it has a unique tagraiseGitDependencyReferenceNotFound,name_match.first.firstifname_match.length==1raiseGitDependencyReferenceNotFound,"(unknown package at #{tag})"endifmessage.match?(GIT_DEPENDENCY_UNREACHABLE_REGEX)url=message.match(GIT_DEPENDENCY_UNREACHABLE_REGEX).named_captures.fetch("url")raiseGitDependenciesNotReachable,urlendraiseDependabot::OutOfDiskifmessage.end_with?("[Errno 28] No space left on device")raiseDependabot::OutOfMemoryifmessage.end_with?("MemoryError")error_handler.handle_pipcompile_error(message)raiseend# rubocop:enable Metrics/AbcSize# rubocop:enable Metrics/PerceivedComplexity# Needed because pip-compile's resolver isn't perfect.# Note: We raise errors from this method, rather than returning a# boolean, so that all deps for this repo will raise identical# errors when failing to updatedefcheck_original_requirements_resolvableSharedHelpers.in_a_temporary_directorydoSharedHelpers.with_git_configured(credentials: credentials)dowrite_temporary_dependency_files(update_requirement: false)filenames_to_compile.eachdo|filename|options=pip_compile_options(filename)options_fingerprint=pip_compile_options_fingerprint(options)run_pip_compile_command("pyenv exec uv pip compile #{options}#{filename}",fingerprint: "pyenv exec uv pip compile #{options_fingerprint} <filename>")endtruerescueSharedHelpers::HelperSubprocessFailed=>e# Pick the error message that includes resolvability errors, this might be the cause from# handle_pip_compile_errors (it's unclear if we should always pick the cause here)error_message=[e.message,e.cause&.message].compact.finddo|msg|msg.include?(RESOLUTION_IMPOSSIBLE_ERROR)endcleaned_message=clean_error_message(error_message||"")raiseifcleaned_message.empty?raiseDependencyFileNotResolvable,cleaned_messageendendenddefrun_command(command,env: python_env,fingerprint:)SharedHelpers.run_shell_command(command,env: env,fingerprint: fingerprint,stderr_to_stdout: true)rescueSharedHelpers::HelperSubprocessFailed=>ehandle_pip_compile_errors(e.message)enddefpip_compile_options_fingerprint(options)options.sub(/--output-file=\S+/,"--output-file=<output_file>").sub(/--index-url=\S+/,"--index-url=<index_url>").sub(/--extra-index-url=\S+/,"--extra-index-url=<extra_index_url>")enddefpip_compile_options(filename)options=@build_isolation?["--build-isolation"]:["--no-build-isolation"]options+=pip_compile_index_options# TODO: Stop explicitly specifying `allow-unsafe` once it becomes the default:# https://github.com/jazzband/pip-tools/issues/989#issuecomment-1661254701options+=["--allow-unsafe"]if(requirements_file=compiled_file_for_filename(filename))options<<"--output-file=#{requirements_file.name}"options+=uv_pip_compile_options_from_compiled_file(requirements_file)endoptions.join(" ")enddefpip_compile_index_optionscredentials.select{|cred|cred["type"]=="python_index"}.mapdo|cred|authed_url=AuthedUrlBuilder.authed_url(credential: cred)ifcred.replaces_base?"--index-url=#{authed_url}"else"--extra-index-url=#{authed_url}"endendenddefrun_pip_compile_command(command,fingerprint:)run_command("pyenv local #{language_version_manager.python_major_minor}",fingerprint: "pyenv local <python_major_minor>")run_command(command,fingerprint: fingerprint)enddefuv_pip_compile_options_from_compiled_file(requirements_file)options=[]options<<"--no-emit-index-url"unlessrequirements_file.content.include?("index-url http")options<<"--generate-hashes"ifrequirements_file.content.include?("--hash=sha")options<<"--no-annotate"unlessrequirements_file.content.include?("# via ")options<<"--pre"ifrequirements_file.content.include?("--pre")options<<"--no-strip-extras"ifrequirements_file.content.include?("--no-strip-extras")ifrequirements_file.content.include?("--no-binary")||requirements_file.content.include?("--only-binary")options<<"--emit-build-options"endif(resolver=FileUpdater::CompileFileUpdater::RESOLVER_REGEX.match(requirements_file.content))options<<"--resolver=#{resolver}"endoptions<<"--universal"ifrequirements_file.content.include?("--universal")optionsenddefpython_envenv={}# Handle Apache Airflow 1.10.x installsifdependency_files.any?{|f|f.content.include?("apache-airflow")}ifdependency_files.any?{|f|f.content.include?("unidecode")}env["AIRFLOW_GPL_UNIDECODE"]="yes"elseenv["SLUGIFY_USES_TEXT_UNIDECODE"]="yes"endendenvenddefwrite_temporary_dependency_files(updated_req: nil,update_requirement: true)dependency_files.eachdo|file|path=file.nameFileUtils.mkdir_p(Pathname.new(path).dirname)updated_content=ifupdate_requirementthenupdate_req_file(file,updated_req)elsefile.contentendFile.write(path,updated_content)end# Overwrite the .python-version with updated contentFile.write(".python-version",language_version_manager.python_major_minor)enddefwrite_original_manifest_filespip_compile_files.eachdo|file|FileUtils.mkdir_p(Pathname.new(file.name).dirname)File.write(file.name,file.content)endenddefupdate_req_file(file,updated_req)returnfile.contentunlessfile.name.end_with?(".in")req=dependency.requirements.find{|r|r[:file]==file.name}returnfile.content+"\n#{dependency.name}#{updated_req}"unlessreq&.fetch(:requirement)Uv::FileUpdater::RequirementReplacer.new(content: file.content,dependency_name: dependency.name,old_requirement: req[:requirement],new_requirement: updated_req).updated_contentenddefnormalise(name)NameNormaliser.normalise(name)enddefclean_error_message(message)message.scan(ERROR_REGEX).lastenddeffilenames_to_compilefiles_from_reqs=dependency.requirements.map{|r|r[:file]}.select{|fn|fn.end_with?(".in")}files_from_compiled_files=pip_compile_files.map(&:name).selectdo|fn|compiled_file=compiled_file_for_filename(fn)compiled_file_includes_dependency?(compiled_file)endfilenames=[*files_from_reqs,*files_from_compiled_files].uniqorder_filenames_for_compilation(filenames)enddefcompiled_file_for_filename(filename)compiled_file=compiled_files.find{|f|f.content.match?(output_file_regex(filename))}compiled_file||=compiled_files.find{|f|f.name==filename.gsub(/\.in$/,".txt")}compiled_fileenddefoutput_file_regex(filename)"--output-file[=\s]+.*\s#{Regexp.escape(filename)}\s*$"enddefcompiled_file_includes_dependency?(compiled_file)returnfalseunlesscompiled_fileregex=RequirementParser::INSTALL_REQ_WITH_REQUIREMENTmatches=[]compiled_file.content.scan(regex){matches<<Regexp.last_match}matches.any?{|m|normalise(m[:name])==dependency.name}end# If the files we need to update require one another then we need to# update them in the right orderdeforder_filenames_for_compilation(filenames)ordered_filenames=T.let([],T::Array[String])while(remaining_filenames=filenames-ordered_filenames).any?ordered_filenames+=remaining_filenames.rejectdo|fn|unupdated_reqs=requirement_map[fn]-ordered_filenamesunupdated_reqs.intersect?(filenames)endendordered_filenamesenddefrequirement_mapchild_req_regex=Uv::FileFetcher::CHILD_REQUIREMENT_REGEX@requirement_map||=pip_compile_files.each_with_object({})do|file,req_map|paths=file.content.scan(child_req_regex).flattencurrent_dir=File.dirname(file.name)req_map[file.name]=paths.mapdo|path|path=File.join(current_dir,path)ifcurrent_dir!="."path=Pathname.new(path).cleanpath.to_pathpath=path.gsub(/\.txt$/,".in")nextifpath==file.namepathend.uniq.compactendenddefparse_updated_filesupdated_files=dependency_files.mapdo|file|nextfileiffile.name==".python-version"updated_file=file.dupupdated_file.content=File.read(file.name)updated_fileendUv::FileParser.new(dependency_files: updated_files,source: nil,credentials: credentials).parse.find{|d|d.name==dependency.name}&.versionenddefpython_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)enddefsetup_filesdependency_files.select{|f|f.name.end_with?("setup.py")}enddefpip_compile_filesdependency_files.select{|f|f.name.end_with?(".in")}enddefcompiled_filesdependency_files.select{|f|f.name.end_with?(".txt")}enddefsetup_cfg_filesdependency_files.select{|f|f.name.end_with?("setup.cfg")}endendendclassPipCompileErrorHandlerSUBPROCESS_ERROR=/subprocess-exited-with-error/INSTALLATION_ERROR=/InstallationError/INSTALLATION_SUBPROCESS_ERROR=/InstallationSubprocessError/HASH_MISMATCH=/HashMismatch/defhandle_pipcompile_error(error)returnunlesserror.match?(SUBPROCESS_ERROR)||error.match?(INSTALLATION_ERROR)||error.match?(INSTALLATION_SUBPROCESS_ERROR)||error.match?(HASH_MISMATCH)raiseDependencyFileNotResolvable,"Error resolving dependency"endendendend