diff --git a/python/lib/dependabot/python/file_parser.rb b/python/lib/dependabot/python/file_parser.rb index 0de97a942d..9ab46bb3fa 100644 --- a/python/lib/dependabot/python/file_parser.rb +++ b/python/lib/dependabot/python/file_parser.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "dependabot/dependency" @@ -17,13 +17,14 @@ module Dependabot module Python - class FileParser < Dependabot::FileParsers::Base + class FileParser < Dependabot::FileParsers::Base # rubocop:disable Metrics/ClassLength + extend T::Sig require_relative "file_parser/pipfile_files_parser" require_relative "file_parser/pyproject_files_parser" require_relative "file_parser/setup_file_parser" require_relative "file_parser/python_requirement_parser" - DEPENDENCY_GROUP_KEYS = [ + DEPENDENCY_GROUP_KEYS = T.let([ { pipfile: "packages", lockfile: "default" @@ -32,7 +33,7 @@ class FileParser < Dependabot::FileParsers::Base pipfile: "dev-packages", lockfile: "develop" } - ].freeze + ].freeze, T::Array[T::Hash[Symbol, String]]) REQUIREMENT_FILE_EVALUATION_ERRORS = %w( InstallationError RequirementsFileParseError InvalidMarker InvalidRequirement ValueError RecursionError @@ -43,6 +44,7 @@ class FileParser < Dependabot::FileParsers::Base # in any way if any metric collection exception start happening UNDETECTED_PACKAGE_MANAGER_VERSION = "0.0" + sig { override.returns(T::Array[Dependabot::Dependency]) } def parse # TODO: setup.py from external dependencies is evaluated. Provide guards before removing this. raise Dependabot::UnexpectedExternalCode if @reject_external_code @@ -57,7 +59,7 @@ def parse dependency_set.dependencies end - sig { returns(Ecosystem) } + sig { override.returns(Ecosystem) } def ecosystem @ecosystem ||= T.let( Ecosystem.new( @@ -71,18 +73,16 @@ def ecosystem private + sig { returns(Dependabot::Python::LanguageVersionManager) } def language_version_manager - @language_version_manager ||= - LanguageVersionManager.new( - python_requirement_parser: python_requirement_parser - ) + @language_version_manager ||= T.let(LanguageVersionManager.new(python_requirement_parser: + python_requirement_parser), T.nilable(LanguageVersionManager)) end + sig { returns(Dependabot::Python::FileParser::PythonRequirementParser) } def python_requirement_parser - @python_requirement_parser ||= - FileParser::PythonRequirementParser.new( - dependency_files: dependency_files - ) + @python_requirement_parser ||= T.let(FileParser::PythonRequirementParser.new(dependency_files: + dependency_files), T.nilable(FileParser::PythonRequirementParser)) end sig { returns(Ecosystem::VersionManager) } @@ -91,7 +91,7 @@ def package_manager Dependabot.logger.info("Detected package manager : #{detected_package_manager.name}") end - @package_manager ||= detected_package_manager + @package_manager ||= T.let(detected_package_manager, T.nilable(Dependabot::Ecosystem::VersionManager)) end sig { returns(Ecosystem::VersionManager) } @@ -188,7 +188,7 @@ def package_manager_version(package_manager) end # setup python local setup on file parser stage - sig { void } + sig { returns(T.nilable(String)) } def setup_python_environment language_version_manager.install_required_python @@ -198,14 +198,15 @@ def setup_python_environment nil end - sig { params(package_manager: String, version: String).void } + sig { params(package_manager: String, version: String).returns(T::Boolean) } def log_if_version_malformed(package_manager, version) # logs warning if malformed version is found - return true if version.match?(/^\d+(?:\.\d+)*$/) - - Dependabot.logger.warn( - "Detected #{package_manager} with malformed version #{version}" - ) + if version.match?(/^\d+(?:\.\d+)*$/) + true + else + Dependabot.logger.warn("Detected #{package_manager} with malformed version #{version}") + false + end end sig { returns(String) } @@ -231,24 +232,24 @@ def language ) end + sig { returns(T::Array[Dependabot::DependencyFile]) } def requirement_files dependency_files.select { |f| f.name.end_with?(".txt", ".in") } end + sig { returns(DependencySet) } def pipenv_dependencies - @pipenv_dependencies ||= - PipfileFilesParser - .new(dependency_files: dependency_files) - .dependency_set + @pipenv_dependencies ||= T.let(PipfileFilesParser.new(dependency_files: + dependency_files).dependency_set, T.nilable(DependencySet)) end + sig { returns(DependencySet) } def pyproject_file_dependencies - @pyproject_file_dependencies ||= - PyprojectFilesParser - .new(dependency_files: dependency_files) - .dependency_set + @pyproject_file_dependencies ||= T.let(PyprojectFilesParser.new(dependency_files: + dependency_files).dependency_set, T.nilable(DependencySet)) end + sig { returns(DependencySet) } def requirement_dependencies dependencies = DependencySet.new parsed_requirement_files.each do |dep| @@ -286,6 +287,7 @@ def requirement_dependencies dependencies end + sig { params(name: T.nilable(String), version: T.nilable(String)).returns(T::Boolean) } def old_pyyaml?(name, version) major_version = version&.split(".")&.first return false unless major_version @@ -293,6 +295,7 @@ def old_pyyaml?(name, version) name == "pyyaml" && major_version < "6" end + sig { params(filename: String).returns(T::Array[String]) } def group_from_filename(filename) if filename.include?("dev") then ["dev-dependencies"] else @@ -300,6 +303,7 @@ def group_from_filename(filename) end end + sig { params(dep: T.untyped).returns(T::Boolean) } def blocking_marker?(dep) return false if dep["markers"] == "None" @@ -316,6 +320,9 @@ def blocking_marker?(dep) end end + sig do + params(marker: T.untyped, python_version: T.any(String, Integer, Gem::Version)).returns(T::Boolean) + end def marker_satisfied?(marker, python_version) conditions = marker.split(/\s+(and|or)\s+/) @@ -337,6 +344,10 @@ def marker_satisfied?(marker, python_version) result end + sig do + params(condition: T.untyped, + python_version: T.any(String, Integer, Gem::Version)).returns(T::Boolean) + end def evaluate_condition(condition, python_version) operator, version = condition.match(/([<>=!]=?)\s*"?([\d.]+)"?/)&.captures @@ -356,13 +367,13 @@ def evaluate_condition(condition, python_version) end end + sig { returns(DependencySet) } def setup_file_dependencies - @setup_file_dependencies ||= - SetupFileParser - .new(dependency_files: dependency_files) - .dependency_set + @setup_file_dependencies ||= T.let(SetupFileParser.new(dependency_files: dependency_files) + .dependency_set, T.nilable(DependencySet)) end + sig { returns(T.untyped) } def parsed_requirement_files SharedHelpers.in_a_temporary_directory do write_temporary_dependency_files @@ -383,6 +394,7 @@ def parsed_requirement_files raise Dependabot::DependencyFileNotEvaluatable, e.message end + sig { params(requirements: T.untyped).returns(T.untyped) } def check_requirements(requirements) requirements.each do |dep| next unless dep["requirement"] @@ -393,18 +405,22 @@ def check_requirements(requirements) end end + sig { returns(T::Boolean) } def pipcompile_in_file requirement_files.any? { |f| f.name.end_with?(PipCompilePackageManager::MANIFEST_FILENAME) } end + sig { returns(T::Boolean) } def pipenv_files dependency_files.any? { |f| f.name == PipenvPackageManager::LOCKFILE_FILENAME } end + sig { returns(T.nilable(TrueClass)) } def poetry_files true if get_original_file(PoetryPackageManager::LOCKFILE_NAME) end + sig { returns(T::Array[Dependabot::DependencyFile]) } def write_temporary_dependency_files dependency_files .reject { |f| f.name == ".python-version" } @@ -415,6 +431,7 @@ def write_temporary_dependency_files end end + sig { params(file: T.untyped).returns(T.untyped) } def remove_imports(file) return file.content if file.path.end_with?(".tar.gz", ".whl", ".zip") @@ -424,10 +441,12 @@ def remove_imports(file) .join end + sig { params(name: String, extras: T::Array[String]).returns(String) } def normalised_name(name, extras = []) NameNormaliser.normalise_including_extras(name, extras) end + sig { override.returns(T.untyped) } def check_required_files filenames = dependency_files.map(&:name) return if filenames.any? { |name| name.end_with?(".txt", ".in") } @@ -439,37 +458,45 @@ def check_required_files raise "Missing required files!" end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def pipfile - @pipfile ||= get_original_file("Pipfile") + @pipfile ||= T.let(get_original_file("Pipfile"), T.nilable(Dependabot::DependencyFile)) end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def pipfile_lock - @pipfile_lock ||= get_original_file("Pipfile.lock") + @pipfile_lock ||= T.let(get_original_file("Pipfile.lock"), T.nilable(Dependabot::DependencyFile)) end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def pyproject - @pyproject ||= get_original_file("pyproject.toml") + @pyproject ||= T.let(get_original_file("pyproject.toml"), T.nilable(Dependabot::DependencyFile)) end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def poetry_lock - @poetry_lock ||= get_original_file("poetry.lock") + @poetry_lock ||= T.let(get_original_file("poetry.lock"), T.nilable(Dependabot::DependencyFile)) end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def setup_file - @setup_file ||= get_original_file("setup.py") + @setup_file ||= T.let(get_original_file("setup.py"), T.nilable(Dependabot::DependencyFile)) end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def setup_cfg_file - @setup_cfg_file ||= get_original_file("setup.cfg") + @setup_cfg_file ||= T.let(get_original_file("setup.cfg"), T.nilable(Dependabot::DependencyFile)) end + sig { returns(T::Array[Dependabot::Python::Requirement]) } def pip_compile_files - @pip_compile_files ||= - dependency_files.select { |f| f.name.end_with?(".in") } + @pip_compile_files ||= T.let(dependency_files.select { |f| f.name.end_with?(".in") }, T.untyped) end + sig { returns(Dependabot::Python::PipCompileFileMatcher) } def pip_compile_file_matcher - @pip_compile_file_matcher ||= PipCompileFileMatcher.new(pip_compile_files) + @pip_compile_file_matcher ||= T.let(PipCompileFileMatcher.new(pip_compile_files), + T.nilable(Dependabot::Python::PipCompileFileMatcher)) end end end diff --git a/python/lib/dependabot/python/file_parser/pyproject_files_parser.rb b/python/lib/dependabot/python/file_parser/pyproject_files_parser.rb index 744d3457dc..2dcf33ba0f 100644 --- a/python/lib/dependabot/python/file_parser/pyproject_files_parser.rb +++ b/python/lib/dependabot/python/file_parser/pyproject_files_parser.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "toml-rb" @@ -14,15 +14,18 @@ module Dependabot module Python class FileParser class PyprojectFilesParser + extend T::Sig POETRY_DEPENDENCY_TYPES = %w(dependencies dev-dependencies).freeze # https://python-poetry.org/docs/dependency-specification/ UNSUPPORTED_DEPENDENCY_TYPES = %w(git path url).freeze + sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).void } def initialize(dependency_files:) @dependency_files = dependency_files end + sig { returns(Dependabot::FileParsers::Base::DependencySet) } def dependency_set dependency_set = Dependabot::FileParsers::Base::DependencySet.new @@ -34,16 +37,18 @@ def dependency_set private + sig { returns(T::Array[Dependabot::DependencyFile]) } attr_reader :dependency_files + sig { returns(Dependabot::FileParsers::Base::DependencySet) } def pyproject_dependencies if using_poetry? missing_keys = missing_poetry_keys if missing_keys.any? raise DependencyFileNotParseable.new( - pyproject.path, - "#{pyproject.path} is missing the following sections:\n" \ + 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 @@ -54,25 +59,28 @@ def pyproject_dependencies end end + sig { returns(Dependabot::FileParsers::Base::DependencySet) } def poetry_dependencies - @poetry_dependencies ||= parse_poetry_dependencies + @poetry_dependencies ||= T.let(parse_poetry_dependencies, T.untyped) end + sig { returns(Dependabot::FileParsers::Base::DependencySet) } def parse_poetry_dependencies dependencies = Dependabot::FileParsers::Base::DependencySet.new POETRY_DEPENDENCY_TYPES.each do |type| - deps_hash = poetry_root[type] || {} + deps_hash = T.must(poetry_root)[type] || {} dependencies += parse_poetry_dependency_group(type, deps_hash) end - groups = poetry_root["group"] || {} + groups = T.must(poetry_root)["group"] || {} groups.each do |group, group_spec| dependencies += parse_poetry_dependency_group(group, group_spec["dependencies"]) end dependencies end + sig { returns(Dependabot::FileParsers::Base::DependencySet) } def pep621_dependencies dependencies = Dependabot::FileParsers::Base::DependencySet.new @@ -107,6 +115,11 @@ def pep621_dependencies dependencies end + sig do + params(type: String, + deps_hash: T::Hash[String, + T.untyped]).returns(Dependabot::FileParsers::Base::DependencySet) + end def parse_poetry_dependency_group(type, deps_hash) dependencies = Dependabot::FileParsers::Base::DependencySet.new @@ -126,11 +139,13 @@ def parse_poetry_dependency_group(type, deps_hash) dependencies end + sig { params(name: String, extras: T::Array[String]).returns(String) } def normalised_name(name, extras) NameNormaliser.normalise_including_extras(name, extras) end # @param req can be an Array, Hash or String that represents the constraints for a dependency + sig { params(req: T.untyped, type: String).returns(T::Array[T::Hash[Symbol, T.nilable(String)]]) } 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) @@ -140,14 +155,14 @@ def parse_requirements_from(req, type) if requirement.is_a?(String) { requirement: requirement, - file: pyproject.name, + file: T.must(pyproject).name, source: nil, groups: [type] } else { requirement: requirement["version"], - file: pyproject.name, + file: T.must(pyproject).name, source: requirement.fetch("source", nil), groups: [type] } @@ -155,26 +170,31 @@ def parse_requirements_from(req, type) end end + sig { returns(T.nilable(T::Boolean)) } def using_poetry? !poetry_root.nil? end + sig { returns(T::Array[String]) } def missing_poetry_keys - package_mode = poetry_root.fetch("package-mode", true) + package_mode = T.must(poetry_root).fetch("package-mode", true) required_keys = package_mode ? %w(name version description authors) : [] - required_keys.reject { |key| poetry_root.key?(key) } + required_keys.reject { |key| T.must(poetry_root).key?(key) } end + sig { returns(T::Boolean) } def using_pep621? !parsed_pyproject.dig("project", "dependencies").nil? || !parsed_pyproject.dig("project", "optional-dependencies").nil? || !parsed_pyproject.dig("build-system", "requires").nil? end + sig { returns(T.nilable(T::Hash[String, T.untyped])) } def poetry_root parsed_pyproject.dig("tool", "poetry") end + sig { returns(T.untyped) } def using_pdm? using_pep621? && pdm_lock end @@ -182,6 +202,7 @@ def using_pdm? # Create a DependencySet where each element has no requirement. Any # requirements will be added when combining the DependencySet with # other DependencySets. + sig { returns(Dependabot::FileParsers::Base::DependencySet) } def lockfile_dependencies dependencies = Dependabot::FileParsers::Base::DependencySet.new @@ -206,13 +227,16 @@ def lockfile_dependencies dependencies end + sig { returns(T::Array[T.nilable(String)]) } def production_dependency_names - @production_dependency_names ||= parse_production_dependency_names + @production_dependency_names ||= T.let(parse_production_dependency_names, + T.nilable(T::Array[T.nilable(String)])) end + sig { returns(T::Array[T.nilable(String)]) } def parse_production_dependency_names SharedHelpers.in_a_temporary_directory do - File.write(pyproject.name, pyproject.content) + File.write(T.must(pyproject).name, T.must(pyproject).content) File.write(lockfile.name, lockfile.content) begin @@ -232,6 +256,7 @@ def parse_production_dependency_names end end + sig { params(dep_name: String).returns(T.untyped) } def version_from_lockfile(dep_name) return unless parsed_lockfile @@ -240,6 +265,7 @@ def version_from_lockfile(dep_name) &.fetch("version", nil) end + sig { params(req: T.untyped).returns(T::Array[Dependabot::Python::Requirement]) } def check_requirements(req) requirement = req.is_a?(String) ? req : req["version"] Python::Requirement.requirements_array(requirement) @@ -247,31 +273,37 @@ def check_requirements(req) raise Dependabot::DependencyFileNotEvaluatable, e.message end + sig { params(name: String).returns(String) } def normalise(name) NameNormaliser.normalise(name) end + sig { returns(T.untyped) } def parsed_pyproject - @parsed_pyproject ||= TomlRB.parse(pyproject.content) + @parsed_pyproject ||= T.let(TomlRB.parse(T.must(pyproject).content), T.untyped) rescue TomlRB::ParseError, TomlRB::ValueOverwriteError - raise Dependabot::DependencyFileNotParseable, pyproject.path + raise Dependabot::DependencyFileNotParseable, T.must(pyproject).path end + sig { returns(T.untyped) } def parsed_poetry_lock - @parsed_poetry_lock ||= TomlRB.parse(poetry_lock.content) + @parsed_poetry_lock ||= T.let(TomlRB.parse(T.must(poetry_lock).content), T.untyped) rescue TomlRB::ParseError, TomlRB::ValueOverwriteError - raise Dependabot::DependencyFileNotParseable, poetry_lock.path + raise Dependabot::DependencyFileNotParseable, T.must(poetry_lock).path end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def pyproject - @pyproject ||= - dependency_files.find { |f| f.name == "pyproject.toml" } + @pyproject ||= T.let(dependency_files.find { |f| f.name == "pyproject.toml" }, + T.nilable(Dependabot::DependencyFile)) end + sig { returns(T.untyped) } def lockfile poetry_lock end + sig { returns(T.untyped) } def parsed_pep621_dependencies SharedHelpers.in_a_temporary_directory do write_temporary_pyproject @@ -279,29 +311,33 @@ def parsed_pep621_dependencies SharedHelpers.run_helper_subprocess( command: "pyenv exec python3 #{NativeHelpers.python_helper_path}", function: "parse_pep621_dependencies", - args: [pyproject.name] + args: [T.must(pyproject).name] ) end end + sig { returns(Integer) } def write_temporary_pyproject - path = pyproject.name + path = T.must(pyproject).name FileUtils.mkdir_p(Pathname.new(path).dirname) - File.write(path, pyproject.content) + File.write(path, T.must(pyproject).content) end + sig { returns(T.untyped) } def parsed_lockfile parsed_poetry_lock if poetry_lock end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def poetry_lock - @poetry_lock ||= - dependency_files.find { |f| f.name == "poetry.lock" } + @poetry_lock ||= T.let(dependency_files.find { |f| f.name == "poetry.lock" }, + T.nilable(Dependabot::DependencyFile)) end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def pdm_lock - @pdm_lock ||= - dependency_files.find { |f| f.name == "pdm.lock" } + @pdm_lock ||= T.let(dependency_files.find { |f| f.name == "pdm.lock" }, + T.nilable(Dependabot::DependencyFile)) end end end diff --git a/python/lib/dependabot/python/file_parser/setup_file_parser.rb b/python/lib/dependabot/python/file_parser/setup_file_parser.rb index a5336e20c2..27cef6e9cc 100644 --- a/python/lib/dependabot/python/file_parser/setup_file_parser.rb +++ b/python/lib/dependabot/python/file_parser/setup_file_parser.rb @@ -1,4 +1,4 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "dependabot/dependency" @@ -8,22 +8,26 @@ require "dependabot/python/file_parser" require "dependabot/python/native_helpers" require "dependabot/python/name_normaliser" +require "sorbet-runtime" module Dependabot module Python class FileParser class SetupFileParser + extend T::Sig INSTALL_REQUIRES_REGEX = /install_requires\s*=\s*\[/m SETUP_REQUIRES_REGEX = /setup_requires\s*=\s*\[/m TESTS_REQUIRE_REGEX = /tests_require\s*=\s*\[/m EXTRAS_REQUIRE_REGEX = /extras_require\s*=\s*\{/m - CLOSING_BRACKET = { "[" => "]", "{" => "}" }.freeze + CLOSING_BRACKET = T.let({ "[" => "]", "{" => "}" }.freeze, T.any(T.untyped, T.untyped)) + sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).void } def initialize(dependency_files:) @dependency_files = dependency_files end + sig { returns(Dependabot::FileParsers::Base::DependencySet) } def dependency_set dependencies = Dependabot::FileParsers::Base::DependencySet.new @@ -54,8 +58,10 @@ def dependency_set private + sig { returns(T::Array[Dependabot::DependencyFile]) } attr_reader :dependency_files + sig { returns(T.untyped) } def parsed_setup_file SharedHelpers.in_a_temporary_directory do write_temporary_dependency_files @@ -77,6 +83,7 @@ def parsed_setup_file parsed_sanitized_setup_file end + sig { returns(T.nilable(T.any(T::Hash[String, T.untyped], String, T::Array[T::Hash[String, T.untyped]]))) } def parsed_sanitized_setup_file SharedHelpers.in_a_temporary_directory do write_sanitized_setup_file @@ -98,8 +105,9 @@ def parsed_sanitized_setup_file [] end + sig { params(requirements: T.untyped).returns(T.untyped) } def check_requirements(requirements) - requirements.each do |dep| + requirements&.each do |dep| next unless dep["requirement"] Python::Requirement.new(dep["requirement"].split(",")) @@ -108,6 +116,7 @@ def check_requirements(requirements) end end + sig { void } def write_temporary_dependency_files dependency_files .reject { |f| f.name == ".python-version" } @@ -123,6 +132,7 @@ def write_temporary_dependency_files # This sanitization is far from perfect (it will fail if any of the # entries are dynamic), but it is an alternative approach to the one # used in parser.py which sometimes succeeds when that has failed. + sig { void } def write_sanitized_setup_file install_requires = get_regexed_req_array(INSTALL_REQUIRES_REGEX) setup_requires = get_regexed_req_array(SETUP_REQUIRES_REGEX) @@ -141,18 +151,21 @@ def write_sanitized_setup_file File.write("setup.py", tmp) end + sig { params(regex: Regexp).returns(T.nilable(String)) } def get_regexed_req_array(regex) return unless (mch = setup_file.content.match(regex)) "[#{mch.post_match[0..closing_bracket_index(mch.post_match, '[')]}" end + sig { params(regex: Regexp).returns(T.nilable(String)) } def get_regexed_req_dict(regex) return unless (mch = setup_file.content.match(regex)) "{#{mch.post_match[0..closing_bracket_index(mch.post_match, '{')]}" end + sig { params(string: String, bracket: String).returns(Integer) } def closing_bracket_index(string, bracket) closes_required = 1 @@ -165,10 +178,12 @@ def closing_bracket_index(string, bracket) 0 end + sig { params(name: String, extras: T::Array[String]).returns(String) } def normalised_name(name, extras) NameNormaliser.normalise_including_extras(name, extras) end + sig { returns(T.untyped) } def setup_file dependency_files.find { |f| f.name == "setup.py" } end diff --git a/python/lib/dependabot/python/language_version_manager.rb b/python/lib/dependabot/python/language_version_manager.rb index 2bd4eb1e26..d921b268ba 100644 --- a/python/lib/dependabot/python/language_version_manager.rb +++ b/python/lib/dependabot/python/language_version_manager.rb @@ -1,12 +1,14 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "dependabot/logger" require "dependabot/python/version" +require "sorbet-runtime" module Dependabot module Python class LanguageVersionManager + extend T::Sig # This list must match the versions specified at the top of `python/Dockerfile` PRE_INSTALLED_PYTHON_VERSIONS = %w( 3.13.1 @@ -17,10 +19,12 @@ class LanguageVersionManager 3.8.20 ).freeze + sig { params(python_requirement_parser: T.untyped).void } def initialize(python_requirement_parser:) @python_requirement_parser = python_requirement_parser end + sig { returns(T.nilable(String)) } def install_required_python # The leading space is important in the version check return if SharedHelpers.run_shell_command("pyenv versions").include?(" #{python_major_minor}.") @@ -30,22 +34,26 @@ def install_required_python ) end + sig { returns(String) } def installed_version # Use `pyenv exec` to query the active Python version output, _status = SharedHelpers.run_shell_command("pyenv exec python --version") version = output.strip.split.last # Extract the version number (e.g., "3.13.1") - version + T.must(version) end + sig { returns(T.untyped) } def python_major_minor - @python_major_minor ||= T.must(Python::Version.new(python_version).segments[0..1]).join(".") + @python_major_minor ||= T.let(T.must(Python::Version.new(python_version).segments[0..1]).join("."), T.untyped) end + sig { returns(String) } def python_version - @python_version ||= python_version_from_supported_versions + @python_version ||= T.let(python_version_from_supported_versions, T.nilable(String)) end + sig { returns(String) } def python_requirement_string if user_specified_python_version if user_specified_python_version.start_with?(/\d/) @@ -59,6 +67,7 @@ def python_requirement_string end end + sig { returns(String) } def python_version_from_supported_versions requirement_string = python_requirement_string @@ -76,10 +85,12 @@ def python_version_from_supported_versions raise ToolVersionNotSupported.new("Python", python_requirement_string, supported_versions) end + sig { returns(T.untyped) } def user_specified_python_version @python_requirement_parser.user_specified_requirements.first end + sig { returns(T.nilable(String)) } def python_version_matching_imputed_requirements compiled_file_python_requirement_markers = @python_requirement_parser.imputed_requirements.map do |r| @@ -88,6 +99,7 @@ def python_version_matching_imputed_requirements python_version_matching(compiled_file_python_requirement_markers) end + sig { params(requirements: T.untyped).returns(T.nilable(String)) } def python_version_matching(requirements) PRE_INSTALLED_PYTHON_VERSIONS.find do |version_string| version = Python::Version.new(version_string) diff --git a/python/lib/dependabot/python/pipenv_runner.rb b/python/lib/dependabot/python/pipenv_runner.rb index f59f0ec2bd..de76e17631 100644 --- a/python/lib/dependabot/python/pipenv_runner.rb +++ b/python/lib/dependabot/python/pipenv_runner.rb @@ -1,19 +1,31 @@ -# typed: true +# typed: strict # frozen_string_literal: true require "dependabot/shared_helpers" require "dependabot/python/file_parser" require "json" +require "sorbet-runtime" module Dependabot module Python class PipenvRunner + extend T::Sig + + sig do + params( + dependency: Dependabot::Dependency, + lockfile: T.nilable(Dependabot::DependencyFile), + language_version_manager: LanguageVersionManager + ) + .void + end def initialize(dependency:, lockfile:, language_version_manager:) @dependency = dependency @lockfile = lockfile @language_version_manager = language_version_manager end + sig { params(constraint: String).returns(String) } def run_upgrade(constraint) constraint = "" if constraint == "*" command = "pyenv exec pipenv upgrade --verbose #{dependency_name}#{constraint}" @@ -22,6 +34,7 @@ def run_upgrade(constraint) run(command, fingerprint: "pyenv exec pipenv upgrade --verbose ") end + sig { params(constraint: String).returns(String) } def run_upgrade_and_fetch_version(constraint) run_upgrade(constraint) @@ -30,6 +43,7 @@ def run_upgrade_and_fetch_version(constraint) fetch_version_from_parsed_lockfile(updated_lockfile) end + sig { params(command: String, fingerprint: T.nilable(String)).returns(String) } def run(command, fingerprint: nil) run_command( "pyenv local #{language_version_manager.python_major_minor}", @@ -41,10 +55,14 @@ def run(command, fingerprint: nil) private + sig { returns(Dependabot::Dependency) } attr_reader :dependency + sig { returns(T.nilable(Dependabot::DependencyFile)) } attr_reader :lockfile + sig { returns(LanguageVersionManager) } attr_reader :language_version_manager + sig { params(updated_lockfile: T.untyped).returns(T.untyped) } def fetch_version_from_parsed_lockfile(updated_lockfile) deps = updated_lockfile[lockfile_section] || {} @@ -52,25 +70,29 @@ def fetch_version_from_parsed_lockfile(updated_lockfile) &.gsub(/^==/, "") end + sig { params(command: String, fingerprint: T.nilable(String)).returns(String) } def run_command(command, fingerprint: nil) SharedHelpers.run_shell_command(command, env: pipenv_env_variables, fingerprint: fingerprint) end + sig { returns(String) } def lockfile_section if dependency.requirements.any? - dependency.requirements.first[:groups].first + T.must(dependency.requirements.first)[:groups].first else Python::FileParser::DEPENDENCY_GROUP_KEYS.each do |keys| section = keys.fetch(:lockfile) - return section if JSON.parse(lockfile.content)[section].keys.any?(dependency_name) + return section if JSON.parse(T.must(T.must(lockfile).content))[section].keys.any?(dependency_name) end end end + sig { returns(String) } def dependency_name dependency.metadata[:original_name] || dependency.name end + sig { returns(T::Hash[String, String]) } def pipenv_env_variables { "PIPENV_YES" => "true", # Install new Python ver if needed