diff --git a/uv/lib/dependabot/uv/file_updater/lock_file_error_handler.rb b/uv/lib/dependabot/uv/file_updater/lock_file_error_handler.rb index 6e45318e00e..0d6974faf35 100644 --- a/uv/lib/dependabot/uv/file_updater/lock_file_error_handler.rb +++ b/uv/lib/dependabot/uv/file_updater/lock_file_error_handler.rb @@ -4,6 +4,7 @@ require "dependabot/errors" require "dependabot/utils" require "dependabot/uv/file_updater" +require "dependabot/uv/name_normaliser" module Dependabot module Uv @@ -14,6 +15,9 @@ class LockFileErrorHandler UV_UNRESOLVABLE_REGEX = /× No solution found when resolving dependencies.*[\s\S]*$/ UV_BUILD_FAILED_REGEX = /× Failed to build.*[\s\S]*$/ RESOLUTION_IMPOSSIBLE_ERROR = "ResolutionImpossible" + UV_REQUIREMENT_TOKEN_REGEX = + /\A[^A-Za-z0-9]*([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)(?:\[[^\]]*\])?(?:===|==|~=|!=|>=|<=|<|>)/ + UV_FORK_MARKER_REGEX = /\{[^{}]*\}/ GIT_DEPENDENCY_UNREACHABLE_REGEX = %r{git clone.*(?https?://[^\s]+)} GIT_REFERENCE_NOT_FOUND_REGEX = /Did not find branch or tag '(?[^\n"']+)'/m @@ -80,6 +84,16 @@ def handle_uv_error(error) raise error end + sig { params(message: String).returns(T::Array[String]) } + def conflict_package_names(message) + message + .gsub(UV_FORK_MARKER_REGEX, "") + .split + .filter_map { |token| token.match(UV_REQUIREMENT_TOKEN_REGEX)&.captures&.first } + .map { |name| NameNormaliser.normalise(name) } + .uniq + end + private sig { params(message: String).void } diff --git a/uv/lib/dependabot/uv/file_updater/lock_file_updater.rb b/uv/lib/dependabot/uv/file_updater/lock_file_updater.rb index cdb53873d61..bc791085e99 100644 --- a/uv/lib/dependabot/uv/file_updater/lock_file_updater.rb +++ b/uv/lib/dependabot/uv/file_updater/lock_file_updater.rb @@ -47,6 +47,9 @@ class LockFileUpdater sig { returns(T.nilable(String)) } attr_reader :target_requirement + sig { returns(T.nilable(T::Array[String])) } + attr_reader :upgrade_package_names + sig do params( dependencies: T::Array[Dependency], @@ -54,7 +57,8 @@ class LockFileUpdater credentials: T::Array[Dependabot::Credential], index_urls: T.nilable(T::Array[T.nilable(String)]), repo_contents_path: T.nilable(String), - target_requirement: T.nilable(String) + target_requirement: T.nilable(String), + upgrade_package_names: T.nilable(T::Array[String]) ).void end def initialize( @@ -63,7 +67,8 @@ def initialize( credentials:, index_urls: nil, repo_contents_path: nil, - target_requirement: nil + target_requirement: nil, + upgrade_package_names: nil ) @dependencies = dependencies @dependency_files = dependency_files @@ -71,6 +76,7 @@ def initialize( @index_urls = index_urls @repo_contents_path = repo_contents_path @target_requirement = target_requirement + @upgrade_package_names = upgrade_package_names @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)) @@ -86,20 +92,14 @@ def updated_dependency_files private - sig { returns(T.nilable(Dependabot::Dependency)) } - def dependency - # For now, we'll only ever be updating a single dependency - T.must(dependencies.first) - end - sig { returns(T::Boolean) } def build_system_only_dependency? - return false unless dependency + return false if dependencies.empty? - groups = T.must(dependency).requirements.flat_map { |req| req.groups || [] }.compact.uniq - return false if groups.empty? - - groups.all?("build-system") + dependencies.all? do |dep| + groups = dep.requirements.flat_map { |req| req.groups || [] }.compact.uniq + !groups.empty? && groups.all?("build-system") + end end sig { returns(T::Array[Dependabot::DependencyFile]) } @@ -146,10 +146,15 @@ def updated_pyproject_content_for(file) 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 == file.name && T.must(old_r).file == file.name + dependencies.each do |dep| + previous_requirements = dep.previous_requirements + next unless previous_requirements + + dep.requirements.zip(previous_requirements).each do |new_r, old_r| + next unless old_r && new_r.file == file.name && old_r.file == file.name - updated_content = replace_dep(T.must(dependency), updated_content, new_r, T.must(old_r)) + updated_content = replace_dep(dep, updated_content, new_r, old_r) + end end raise DependencyFileContentNotChanged, "Content did not change!" if content == updated_content @@ -170,7 +175,7 @@ def replace_dep(dep, content, new_r, old_r) old_req = old_r.requirement_string escaped_name = escape_package_name(dep.name) - regex = /(["']#{escaped_name})([^"']+)(["'])/x + regex = /(["']#{escaped_name})(?![A-Za-z0-9._-])([^"']+)(["'])/x replaced = T.let(false, T::Boolean) @@ -296,32 +301,50 @@ def error_handler def run_update_command options = lock_options options_fingerprint = lock_options_fingerprint(options) + package_specs = upgrade_package_specs # Use pyenv exec to ensure we're using the correct Python environment - # Include the target version to respect ignore conditions and avoid upgrading - # to the absolute latest version (which may be blocked by ignore rules) - dep_name = T.must(dependency).name - dep_version = T.must(dependency).version - # Strip extras from the package name for the uv lock command - # uv lock --upgrade-package expects the base package name without extras - base_dep_name = normalise(dep_name) - package_spec = - if target_requirement - "#{base_dep_name}#{target_requirement}" - elsif dep_version - "#{base_dep_name}==#{dep_version}" - else - base_dep_name - end - - command = "pyenv exec uv lock --upgrade-package #{package_spec} #{options}" - fingerprint = "pyenv exec uv lock --upgrade-package #{options_fingerprint}" + upgrade_flags = package_specs.map { |spec| "--upgrade-package #{spec}" }.join(" ") + fingerprint_flags = package_specs.map { "--upgrade-package " }.join(" ") + command = "pyenv exec uv lock #{upgrade_flags} #{options}" + fingerprint = "pyenv exec uv lock #{fingerprint_flags} #{options_fingerprint}" env_vars = pyproject_index_env_vars.merge(setuptools_scm_pretend_version_env_vars) run_command(command, fingerprint: fingerprint, env: env_vars) end + sig { returns(T::Array[String]) } + def upgrade_package_specs + names_to_upgrade = upgrade_package_names&.map { |name| normalise(name) } + specs = T.let({}, T::Hash[String, String]) + + dependencies.each_with_index do |dep, index| + # uv lock --upgrade-package expects the base package name without extras + base_name = normalise(dep.name) + next if specs.key?(base_name) + next if names_to_upgrade && !names_to_upgrade.include?(base_name) + + specs[base_name] = upgrade_package_spec(dep, base_name, first: index.zero?) + end + + specs.values + end + + # Pin each package to its target so ignore conditions are respected and uv + # doesn't jump to the latest version. target_requirement is a single + # constraint, so it only applies to the first dependency. + sig { params(dep: Dependency, base_name: String, first: T::Boolean).returns(String) } + def upgrade_package_spec(dep, base_name, first:) + if first && target_requirement + "#{base_name}#{target_requirement}" + elsif dep.version + "#{base_name}==#{dep.version}" + else + base_name + end + end + sig { params(command: String, fingerprint: T.nilable(String), env: T::Hash[String, String]).returns(String) } def run_command(command, fingerprint: nil, env: {}) Dependabot.logger.info("Running command: #{command}") @@ -738,9 +761,11 @@ def uv_lock sig { returns(T::Boolean) } def create_or_update_lock_file? - return true if lockfile && T.must(dependency).requirements.empty? + dependencies.any? do |dep| + next true if lockfile && dep.requirements.empty? - T.must(dependency).requirements.any? { |req| req.file&.end_with?(*REQUIRED_FILES) } + dep.requirements.any? { |req| req.file&.end_with?(*REQUIRED_FILES) } + end end sig { returns(T::Hash[String, String]) } diff --git a/uv/lib/dependabot/uv/update_checker.rb b/uv/lib/dependabot/uv/update_checker.rb index 5dd0991e1c9..e55863b2a87 100644 --- a/uv/lib/dependabot/uv/update_checker.rb +++ b/uv/lib/dependabot/uv/update_checker.rb @@ -8,6 +8,7 @@ require "dependabot/dependency" require "dependabot/dependency_requirement" require "dependabot/errors" +require "dependabot/experiments" require "dependabot/uv/name_normaliser" require "dependabot/uv/requirement_parser" require "dependabot/uv/requirement" @@ -31,6 +32,7 @@ class UpdateChecker < Dependabot::Python::UpdateChecker require_relative "update_checker/requirements_updater" require_relative "update_checker/latest_version_finder" require_relative "update_checker/lock_file_resolver" + require_relative "update_checker/lockstep_resolver" sig { override.returns(T::Array[Dependabot::DependencyRequirement]) } def updated_requirements @@ -42,8 +44,71 @@ def updated_requirements ).updated_requirements end + sig { override.returns(T.nilable(Gem::Version)) } + def latest_resolvable_version + lockstep_checked(super) + end + + sig { override.returns(T.nilable(Gem::Version)) } + def lowest_resolvable_security_fix_version + lockstep_checked(super) + end + private + sig { override.returns(T::Boolean) } + def latest_version_resolvable_with_full_unlock? + !lockstep_updates.nil? + end + + sig { override.returns(T::Array[Dependabot::Dependency]) } + def updated_dependencies_after_full_unlock + T.must(lockstep_updates) + end + + # Relies on `:own` having been asked first: that probe records `rejected_version`, without which this is nil. + sig { returns(T.nilable(T::Array[Dependabot::Dependency])) } + def lockstep_updates + return unless lockstep_check_applies? + + target = lockstep_resolver.rejected_version + return unless target + + lockstep_resolver.updated_dependencies_after_full_unlock(target) + end + + # `:own` claims any registry version is resolvable for pyproject dependencies. When another direct + # dependency is linked to this one in uv.lock, ask uv, so a lockstep conflict falls through to a full unlock. + sig { params(candidate: T.nilable(Gem::Version)).returns(T.nilable(Gem::Version)) } + def lockstep_checked(candidate) + return candidate if candidate.nil? || !lockstep_check_applies? + + lockstep_resolver.lockstep_conflict?(candidate) ? nil : candidate + end + + sig { returns(T::Boolean) } + def lockstep_check_applies? + Dependabot::Experiments.enabled?(:uv_lockstep_full_unlock) && + uv_lock.any? && + resolver_type == :requirements && + lockstep_resolver.neighbours_in_lockfile? + end + + sig { returns(LockstepResolver) } + def lockstep_resolver + @lockstep_resolver ||= T.let( + LockstepResolver.new( + dependency: dependency, + dependency_files: dependency_files, + credentials: credentials, + repo_contents_path: repo_contents_path, + requirements_update_strategy: requirements_update_strategy, + update_cooldown: @update_cooldown + ), + T.nilable(LockstepResolver) + ) + end + sig { override.returns(T.nilable(Gem::Version)) } def fetch_lowest_resolvable_security_fix_version fix_version = lowest_security_fix_version diff --git a/uv/lib/dependabot/uv/update_checker/lockstep_resolver.rb b/uv/lib/dependabot/uv/update_checker/lockstep_resolver.rb new file mode 100644 index 00000000000..c6f091e2d0d --- /dev/null +++ b/uv/lib/dependabot/uv/update_checker/lockstep_resolver.rb @@ -0,0 +1,361 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/dependency" +require "dependabot/errors" +require "dependabot/package/release_cooldown_options" +require "dependabot/requirements_update_strategy" +require "dependabot/shared_helpers" +require "dependabot/uv/file_parser" +require "dependabot/uv/file_updater/lock_file_error_handler" +require "dependabot/uv/file_updater/lock_file_updater" +require "dependabot/uv/lockfile_document" +require "dependabot/uv/name_normaliser" +require "dependabot/uv/version" +require "dependabot/uv/update_checker" +require "dependabot/uv/update_checker/latest_version_finder" +require "dependabot/uv/update_checker/requirements_updater" + +module Dependabot + module Uv + class UpdateChecker + # Moves a dependency together with the direct dependencies it is pinned in lockstep with + # (e.g. opentelemetry-api and opentelemetry-sdk), which uv can only resolve when both pins move. + class LockstepResolver + extend T::Sig + + MAX_UNLOCK_ROUNDS = 3 + UV_NO_SOLUTION_ERROR = "No solution found when resolving dependencies" + + class Probe < T::ImmutableStruct + const :resolved, T::Boolean + const :locked_versions, T::Hash[String, T::Array[String]], default: {} + const :conflict_names, T::Array[String], default: [] + end + + sig { returns(T.nilable(Gem::Version)) } + attr_reader :rejected_version + + sig do + params( + dependency: Dependabot::Dependency, + dependency_files: T::Array[Dependabot::DependencyFile], + credentials: T::Array[Dependabot::Credential], + repo_contents_path: T.nilable(String), + requirements_update_strategy: Dependabot::RequirementsUpdateStrategy, + update_cooldown: T.nilable(Dependabot::Package::ReleaseCooldownOptions) + ).void + end + def initialize( + dependency:, + dependency_files:, + credentials:, + repo_contents_path:, + requirements_update_strategy:, + update_cooldown: + ) + @dependency = dependency + @dependency_files = dependency_files + @credentials = credentials + @repo_contents_path = repo_contents_path + @requirements_update_strategy = requirements_update_strategy + @update_cooldown = update_cooldown + @rejected_version = T.let(nil, T.nilable(Gem::Version)) + @own_probes = T.let({}, T::Hash[String, Probe]) + @full_unlock_updates = T.let({}, T::Hash[String, T.nilable(T::Array[Dependabot::Dependency])]) + @neighbours_in_lockfile = T.let(nil, T.nilable(T::Boolean)) + @top_level_dependencies = T.let(nil, T.nilable(T::Array[Dependabot::Dependency])) + @original_locked_versions = T.let(nil, T.nilable(T::Hash[String, T::Array[String]])) + end + + sig { returns(T::Boolean) } + def neighbours_in_lockfile? + @neighbours_in_lockfile = compute_neighbours_in_lockfile if @neighbours_in_lockfile.nil? + @neighbours_in_lockfile + end + + # Only a resolution conflict that names another direct dependency counts. Other resolution failures + # (e.g. a release that needs a newer Python) return false so the file updater still reports them; + # errors that aren't resolution failures are raised here. + sig { params(version: Gem::Version).returns(T::Boolean) } + def lockstep_conflict?(version) + result = own_probe(version) + conflict = !result.resolved && eligible_peers(result.conflict_names).any? + @rejected_version = version if conflict + conflict + end + + sig { params(version: Gem::Version).returns(T.nilable(T::Array[Dependabot::Dependency])) } + def updated_dependencies_after_full_unlock(version) + key = version.to_s + return @full_unlock_updates[key] if @full_unlock_updates.key?(key) + + @full_unlock_updates[key] = resolve_with_peers(version) + end + + private + + sig { returns(Dependabot::Dependency) } + attr_reader :dependency + + sig { returns(T::Array[Dependabot::DependencyFile]) } + attr_reader :dependency_files + + sig { returns(T::Array[Dependabot::Credential]) } + attr_reader :credentials + + sig { returns(T.nilable(String)) } + attr_reader :repo_contents_path + + sig { returns(Dependabot::RequirementsUpdateStrategy) } + attr_reader :requirements_update_strategy + + sig { returns(T.nilable(Dependabot::Package::ReleaseCooldownOptions)) } + attr_reader :update_cooldown + + sig { params(version: Gem::Version).returns(T.nilable(T::Array[Dependabot::Dependency])) } + def resolve_with_peers(version) + peers = T.let([], T::Array[Dependabot::Dependency]) + + MAX_UNLOCK_ROUNDS.times do + # Round 1 has no peers: reuse the cached own probe so :own then :all runs uv once for it + result = peers.empty? ? own_probe(version) : probe(version, peers) + return updates_from(version, peers, result) if result.resolved + + new_peers = eligible_peers(result.conflict_names).reject do |peer| + peers.any? { |existing| normalise(existing.name) == normalise(peer.name) } + end + return nil if new_peers.empty? + + peers += new_peers + end + + nil + end + + sig do + params( + version: Gem::Version, + peers: T::Array[Dependabot::Dependency], + result: Probe + ).returns(T.nilable(T::Array[Dependabot::Dependency])) + end + def updates_from(version, peers, result) + return nil unless result.locked_versions[normalise(dependency.name)] == [version.to_s] + + moved = T.let([], T::Array[Dependabot::Dependency]) + peers.each do |peer| + versions = result.locked_versions.fetch(normalise(peer.name), []) + # A single pin can't express a forked resolution + return nil unless versions.length == 1 + + new_version = T.must(versions.first) + next if new_version == peer.version + return nil if in_cooldown?(peer, new_version) + + moved << updated_dependency(peer, new_version) + end + return nil if moved.empty? + + [updated_dependency(dependency, version.to_s), *moved.sort_by(&:name)] + end + + sig { params(version: Gem::Version).returns(Probe) } + def own_probe(version) + @own_probes[version.to_s] ||= probe(version, []) + end + + sig { params(version: Gem::Version, peers: T::Array[Dependabot::Dependency]).returns(Probe) } + def probe(version, peers) + updated_files = FileUpdater::LockFileUpdater.new( + dependencies: [ + updated_dependency(dependency, version.to_s), + *peers.map { |peer| relaxed_dependency(peer) } + ], + dependency_files: dependency_files, + credentials: credentials, + repo_contents_path: repo_contents_path, + upgrade_package_names: [dependency.name] + ).updated_dependency_files + + lockfile = updated_files.find { |file| file.name == "uv.lock" } || T.must(original_lockfile) + Probe.new(resolved: true, locked_versions: locked_versions(lockfile)) + rescue Dependabot::DependencyFileContentNotChanged + Probe.new(resolved: true, locked_versions: original_locked_versions) + rescue Dependabot::UpdateNotPossible => e + Probe.new(resolved: false, conflict_names: update_not_possible_names(e)) + rescue Dependabot::DependencyFileNotResolvable => e + raise unless resolution_conflict?(e.message) + + Probe.new(resolved: false, conflict_names: error_handler.conflict_package_names(e.message)) + end + + # UpdateNotPossible only names the first two packages of uv's derivation, so a peer further down + # (e.g. an sdk depending on the api through a third package) is read from the original uv output. + sig { params(error: Dependabot::UpdateNotPossible).returns(T::Array[String]) } + def update_not_possible_names(error) + names = error.dependencies.map { |name| normalise(name) } + cause = error.cause + return names unless cause.is_a?(SharedHelpers::HelperSubprocessFailed) + + (error_handler.conflict_package_names(cause.message) + names).uniq + end + + sig { params(message: String).returns(T::Boolean) } + def resolution_conflict?(message) + message.include?(UV_NO_SOLUTION_ERROR) || + message.include?(FileUpdater::LockFileErrorHandler::RESOLUTION_IMPOSSIBLE_ERROR) + end + + sig { params(names: T::Array[String]).returns(T::Array[Dependabot::Dependency]) } + def eligible_peers(names) + wanted = names.map { |name| normalise(name) } - [normalise(dependency.name)] + + eligible = top_level_dependencies.select do |dep| + name = normalise(dep.name) + wanted.include?(name) && + !dep.version.nil? && + !build_system_only?(dep) && + !local_package_names.include?(name) && + original_locked_versions.fetch(name, []).length == 1 + end + eligible.uniq { |dep| normalise(dep.name) } + end + + sig { params(dep: Dependabot::Dependency, version: String).returns(Dependabot::Dependency) } + def updated_dependency(dep, version) + Dependabot::Dependency.new( + name: dep.name, + version: version, + previous_version: dep.version, + requirements: RequirementsUpdater.new( + requirements: dep.requirements, + latest_resolvable_version: version, + update_strategy: requirements_update_strategy, + has_lockfile: dep.requirements.any? { |req| req.file&.end_with?("requirements.txt") } + ).updated_requirements, + previous_requirements: dep.requirements, + package_manager: dep.package_manager, + metadata: dep.metadata, + subdependency_metadata: dep.subdependency_metadata + ) + end + + # `>=` the locked version without --upgrade-package: uv keeps the peer unless the bump forces it + # to move, then picks the highest version that fits. + sig { params(peer: Dependabot::Dependency).returns(Dependabot::Dependency) } + def relaxed_dependency(peer) + Dependabot::Dependency.new( + name: peer.name, + version: nil, + previous_version: peer.version, + requirements: peer.requirements.map do |req| + next req unless req.file&.end_with?("pyproject.toml") + + req.merge(requirement: ">=#{peer.version}") + end, + previous_requirements: peer.requirements, + package_manager: peer.package_manager + ) + end + + sig { params(peer: Dependabot::Dependency, new_version: String).returns(T::Boolean) } + def in_cooldown?(peer, new_version) + return false if update_cooldown.nil? + + latest_allowed = LatestVersionFinder.new( + dependency: peer, + dependency_files: dependency_files, + credentials: credentials, + ignored_versions: [], + raise_on_ignored: false, + cooldown_options: update_cooldown, + security_advisories: [] + ).latest_version + latest_allowed.nil? || Uv::Version.new(new_version) > latest_allowed + end + + sig { returns(T::Boolean) } + def compute_neighbours_in_lockfile + return false unless original_lockfile + + name = normalise(dependency.name) + others = top_level_dependencies.map { |dep| normalise(dep.name) }.uniq - [name] + return false if others.empty? + + lockfile_document.graph_packages.any? do |package| + package_name = package.name + next false if package_name.nil? + + linked = linked_names(package) + if normalise(package_name) == name + linked.intersect?(others) + else + others.include?(normalise(package_name)) && linked.include?(name) + end + end + end + + sig { params(package: LockfileDocument::GraphPackage).returns(T::Array[String]) } + def linked_names(package) + (package.dependencies + package.optional_dependencies + package.dev_dependencies) + .map { |linked| normalise(linked) } + end + + sig { params(dep: Dependabot::Dependency).returns(T::Boolean) } + def build_system_only?(dep) + groups = dep.requirements.flat_map { |req| req.groups || [] }.compact.uniq + !groups.empty? && groups.all?("build-system") + end + + sig { returns(T::Array[String]) } + def local_package_names + lockfile_document.graph_packages.select(&:local_source).filter_map(&:name).map { |name| normalise(name) } + end + + sig { returns(T::Array[Dependabot::Dependency]) } + def top_level_dependencies + @top_level_dependencies ||= + FileParser.new(dependency_files: dependency_files, source: nil, credentials: credentials) + .parse + .select { |dep| dep.requirements.any? { |req| req.file&.end_with?("pyproject.toml") } } + end + + sig { returns(T::Hash[String, T::Array[String]]) } + def original_locked_versions + @original_locked_versions ||= locked_versions(T.must(original_lockfile)) + end + + sig { params(lockfile: Dependabot::DependencyFile).returns(T::Hash[String, T::Array[String]]) } + def locked_versions(lockfile) + versions = T.let(Hash.new { |hash, key| hash[key] = [] }, T::Hash[String, T::Array[String]]) + LockfileDocument.from_file(lockfile).resolution_packages.each do |package| + T.must(versions[normalise(package.name)]) << package.version + end + versions.transform_values(&:uniq) + end + + sig { returns(LockfileDocument) } + def lockfile_document + LockfileDocument.from_file(T.must(original_lockfile)) + end + + sig { returns(T.nilable(Dependabot::DependencyFile)) } + def original_lockfile + dependency_files.find { |file| file.name == "uv.lock" } + end + + sig { returns(FileUpdater::LockFileErrorHandler) } + def error_handler + FileUpdater::LockFileErrorHandler.new + end + + sig { params(name: String).returns(String) } + def normalise(name) + NameNormaliser.normalise(name) + end + end + end + end +end diff --git a/uv/spec/dependabot/uv/file_updater/lock_file_error_handler_spec.rb b/uv/spec/dependabot/uv/file_updater/lock_file_error_handler_spec.rb index 4a7acccf646..9bc279a35e4 100644 --- a/uv/spec/dependabot/uv/file_updater/lock_file_error_handler_spec.rb +++ b/uv/spec/dependabot/uv/file_updater/lock_file_error_handler_spec.rb @@ -559,4 +559,178 @@ end end end + + describe "#conflict_package_names" do + subject(:names) { error_handler.conflict_package_names(message) } + + context "when the bumped package needs a newer peer" do + let(:message) do + <<~ERROR + × No solution found when resolving dependencies for split (markers: + │ python_full_version >= '3.12'): + ╰─▶ Because opentelemetry-sdk==1.25.0 depends on opentelemetry-api==1.25.0 + and your project depends on opentelemetry-api==1.26.0, we can conclude + that your project and opentelemetry-sdk==1.25.0 are incompatible. + And because your project depends on opentelemetry-sdk==1.25.0, we can + conclude that your project's requirements are unsatisfiable. + ERROR + end + + it "returns every package named in the conflict, without markers" do + expect(names).to eq(%w(opentelemetry-sdk opentelemetry-api)) + end + end + + context "when a requirement has extras and mixed case" do + let(:message) do + "Because Foo_Bar[extra]>=2.0 depends on baz<1 and your project depends on baz==1.2, ..." + end + + it "normalises the names" do + expect(names).to eq(%w(foo-bar baz)) + end + end + + context "when a requirement has extras and a marker" do + let(:message) { "Because foo[bar]{python_full_version >= '3.12'}>=1 depends on ..." } + + it "returns the name only" do + expect(names).to eq(["foo"]) + end + end + + context "when a name ends in a non-alphanumeric character" do + let(:message) { "Because foo-==1 is odd" } + + it "returns no name" do + expect(names).to eq([]) + end + end + + context "when a name is a single character" do + let(:message) { "Because a==1 is short" } + + it "returns the name" do + expect(names).to eq(["a"]) + end + end + + context "with real uv conflict messages" do + # crates/uv/tests/lock/lock.rs:4319 (astral-sh/uv@5411378e) + it "reads a direct conflict between two packages" do + message = <<~ERROR + error: No solution found when resolving dependencies + cause: Because anyio==3.7.0 depends on idna==3.2 and your project depends on anyio==3.7.0, we can conclude that your project depends on idna==3.2. + And because your project depends on idna==3.6, we can conclude that your project's requirements are unsatisfiable. + ERROR + expect(error_handler.conflict_package_names(message)).to eq(%w(anyio idna)) + end + + # crates/uv/tests/lock/lock.rs:41086 (astral-sh/uv@5411378e) + it "reads a peer forked by a marker" do + message = <<~ERROR + error: No solution found when resolving dependencies for split (markers: python_full_version >= '3.11') + cause: Because pandas==1.5.3 depends on numpy{python_full_version >= '3.10'}>=1.21.0 and your project depends on numpy==1.20.3, we can conclude that your project and pandas==1.5.3 are incompatible. + And because your project depends on pandas==1.5.3, we can conclude that your project's requirements are unsatisfiable. + ERROR + expect(error_handler.conflict_package_names(message)).to eq(%w(pandas numpy)) + end + + # crates/uv/tests/lock/lock.rs:43886 (astral-sh/uv@5411378e) + it "reads a forked package with and without a specifier" do + message = <<~ERROR + error: No solution found when resolving dependencies for split (markers: python_full_version < '3.14' and sys_platform == 'other') + cause: Because your project depends on anyio{sys_platform == 'other'} and anyio{python_full_version < '3.14'}>=4.4.0, we can conclude that your project's requirements are unsatisfiable. + ERROR + expect(error_handler.conflict_package_names(message)).to eq(%w(anyio)) + end + + # generated with uv 0.11.25 (`uv lock`, opentelemetry-sdk/api pinned under `python_version` markers) + it "reads a forked marker that uv wrapped across lines" do + message = <<~ERROR + × No solution found when resolving dependencies for split (markers: + │ python_full_version >= '3.12'): + ╰─▶ Because opentelemetry-sdk==1.25.0 depends on opentelemetry-api==1.25.0 + and your project depends on opentelemetry-api{python_full_version + >= '3.12'}==1.26.0, we can conclude that your project and + opentelemetry-sdk{python_full_version >= '3.12'}==1.25.0 are + incompatible. + And because your project depends on + opentelemetry-sdk{python_full_version >= '3.12'}==1.25.0, we can + conclude that your project's requirements are unsatisfiable. + ERROR + expect(error_handler.conflict_package_names(message)).to eq(%w(opentelemetry-sdk opentelemetry-api)) + end + + # crates/uv/tests/pip_compile/pip_compile.rs:11566 (astral-sh/uv@5411378e) + it "reads a package with extras" do + message = <<~ERROR + error: No solution found when resolving dependencies + cause: Because only recursive-demo[outer]==1.0.0 is available and recursive-demo[outer]==1.0.0 depends on recursive-demo{sys_platform == 'darwin'}>=2, we can conclude that all versions of recursive-demo[outer] cannot be used. + And because you require recursive-demo[outer], we can conclude that your requirements are unsatisfiable. + ERROR + expect(error_handler.conflict_package_names(message)).to eq(%w(recursive-demo)) + end + + # crates/uv/tests/lock/lock.rs:6896 (astral-sh/uv@5411378e) + it "skips dependency groups and the project's own extras" do + message = <<~ERROR + error: No solution found when resolving dependencies + cause: Because project:project1 depends on sortedcontainers==2.3.0 and project[project2] depends on sortedcontainers==2.4.0, we can conclude that project:project1 and project[project2] are incompatible. + And because your project requires project[project2] and project:project1, we can conclude that your project's requirements are unsatisfiable. + ERROR + expect(error_handler.conflict_package_names(message)).to eq(%w(sortedcontainers)) + end + + # crates/uv/tests/sync/sync.rs:2496 (astral-sh/uv@5411378e) + it "reads a Python conflict reached through a dependency group" do + message = <<~ERROR + error: No solution found when resolving dependencies for split (markers: python_full_version == '3.8.*') + cause: Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used. + And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used. + And because pharaohs-tomp:mygroup depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:mygroup, we can conclude that your project's requirements are unsatisfiable. + ERROR + # `python` is not a package; the top-level dependency filter drops it. + expect(error_handler.conflict_package_names(message)).to eq(%w(python sphinx)) + end + + # crates/uv/tests/lock/lock.rs:8207 (astral-sh/uv@5411378e) + it "reads a Python conflict with version ranges" do + message = <<~ERROR + error: No solution found when resolving dependencies for split (markers: python_full_version >= '3.7' and python_full_version < '3.7.9') + cause: Because the requested Python version (>=3.7) does not satisfy Python>=3.7.9 and pygls>=1.1.0,<=1.2.1 depends on Python>=3.7.9,<4, we can conclude that pygls>=1.1.0,<=1.2.1 cannot be used. + And because only the following versions of pygls are available: + pygls<=1.2.1 + pygls>=1.3.0 + we can conclude that pygls>=1.1.0,<1.3.0 cannot be used. (1) + + Because the requested Python version (>=3.7) does not satisfy Python>=3.8 and pygls==1.3.0 depends on Python>=3.8, we can conclude that pygls==1.3.0 cannot be used. + And because only pygls<=1.3.0 is available, we can conclude that pygls>=1.3.0 cannot be used. + And because we know from (1) that pygls>=1.1.0,<1.3.0 cannot be used, we can conclude that pygls>=1.1.0 cannot be used. + And because your project depends on pygls>=1.1.0, we can conclude that your project's requirements are unsatisfiable. + ERROR + # `python` is not a package; the top-level dependency filter drops it. + expect(error_handler.conflict_package_names(message)).to eq(%w(python pygls)) + end + + # crates/uv/tests/lock/lock.rs:41891 (astral-sh/uv@5411378e) + it "reads a wildcard specifier" do + message = <<~ERROR + error: No solution found when resolving dependencies + cause: Because only anyio<=4.3.0 is available and your project depends on anyio==5.4.*, we can conclude that your project's requirements are unsatisfiable. + ERROR + expect(error_handler.conflict_package_names(message)).to eq(%w(anyio)) + end + + # crates/uv/tests/pip_compile/pip_compile.rs:19102 (astral-sh/uv@5411378e) + it "reads local versions and skips uv's virtual system packages" do + message = <<~ERROR + error: No solution found when resolving dependencies + cause: Because torchvision==0.17.1+cu118 depends on system:cuda==11.8 and torch>=2.2.1+cu121 depends on system:cuda==12.1, we can conclude that torch>=2.2.1+cu121 and torchvision==0.17.1+cu118 are incompatible. + And because you require torch==2.2.1+cu121 and torchvision==0.17.1+cu118, we can conclude that your requirements are unsatisfiable. + ERROR + expect(error_handler.conflict_package_names(message)).to eq(%w(torchvision torch)) + end + end + end end diff --git a/uv/spec/dependabot/uv/file_updater/lock_file_updater_spec.rb b/uv/spec/dependabot/uv/file_updater/lock_file_updater_spec.rb index e2fbd24789a..9e35bdd0731 100644 --- a/uv/spec/dependabot/uv/file_updater/lock_file_updater_spec.rb +++ b/uv/spec/dependabot/uv/file_updater/lock_file_updater_spec.rb @@ -14,7 +14,8 @@ dependency_files: dependency_files, credentials: credentials, index_urls: index_urls, - target_requirement: target_requirement + target_requirement: target_requirement, + upgrade_package_names: upgrade_package_names ) end @@ -31,6 +32,7 @@ end let(:index_urls) { [] } let(:target_requirement) { nil } + let(:upgrade_package_names) { nil } let(:dependency) do Dependabot::Dependency.new( @@ -1437,6 +1439,92 @@ end end + context "with several dependencies" do + let(:peer) do + Dependabot::Dependency.new( + name: "urllib3", + version: "2.2.3", + requirements: [{ file: "pyproject.toml", requirement: "==2.2.3", groups: [], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==2.2.2", groups: [], source: nil }], + previous_version: "2.2.2", + package_manager: "uv" + ) + end + let(:dependencies) { [dependency, peer] } + + it "upgrades all of them in a single lock" do + expected_command = "pyenv exec uv lock --upgrade-package requests==2.23.0 " \ + "--upgrade-package urllib3==2.2.3 " \ + "--index https://token@example.com/simple " \ + "--default-index https://another_token@another.com/simple" + expected_fingerprint = "pyenv exec uv lock --upgrade-package " \ + "--upgrade-package " \ + "--index " \ + "--default-index " + + run_update_command + + expect(updater).to have_received(:run_command).with( + expected_command, + fingerprint: expected_fingerprint, + env: {} + ) + end + + context "with a target requirement" do + let(:target_requirement) { ">=2.19.0,<=2.20.0" } + + it "applies the target requirement to the first dependency only" do + expected_command = "pyenv exec uv lock --upgrade-package requests>=2.19.0,<=2.20.0 " \ + "--upgrade-package urllib3==2.2.3 " \ + "--index https://token@example.com/simple " \ + "--default-index https://another_token@another.com/simple" + + run_update_command + + expect(updater).to have_received(:run_command).with(expected_command, fingerprint: anything, env: {}) + end + end + + context "when only some of them should be upgraded" do + let(:upgrade_package_names) { ["Requests"] } + + it "only passes --upgrade-package for those" do + expected_command = "pyenv exec uv lock --upgrade-package requests==2.23.0 " \ + "--index https://token@example.com/simple " \ + "--default-index https://another_token@another.com/simple" + + run_update_command + + expect(updater).to have_received(:run_command).with(expected_command, fingerprint: anything, env: {}) + end + end + + context "when two of them are the same package with different extras" do + let(:peer) do + Dependabot::Dependency.new( + name: "requests[socks]", + version: "2.24.0", + requirements: [{ file: "pyproject.toml", requirement: "==2.24.0", groups: [], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==2.22.0", groups: [], source: nil }], + previous_version: "2.22.0", + package_manager: "uv" + ) + end + let(:target_requirement) { ">=2.19.0,<=2.20.0" } + + it "passes the package once" do + expected_command = "pyenv exec uv lock --upgrade-package requests>=2.19.0,<=2.20.0 " \ + "--index https://token@example.com/simple " \ + "--default-index https://another_token@another.com/simple" + + run_update_command + + expect(updater).to have_received(:run_command).with(expected_command, fingerprint: anything, env: {}) + end + end + end + context "with setuptools-scm dynamic versioning" do let(:pyproject_content) { fixture("pyproject_files", "setuptools_scm_version_file.toml") } let(:credentials) { [] } @@ -1654,6 +1742,130 @@ end end + describe "with several dependencies to update" do + let(:pyproject_content) do + <<~TOML + [project] + name = "demo" + version = "0.1.0" + dependencies = [ + "opentelemetry-api==1.25.0", + "opentelemetry-sdk==1.25.0", + ] + TOML + end + let(:dependency) do + Dependabot::Dependency.new( + name: "opentelemetry-api", + version: "1.26.0", + requirements: [{ file: "pyproject.toml", requirement: "==1.26.0", groups: [], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==1.25.0", groups: [], source: nil }], + previous_version: "1.25.0", + package_manager: "uv" + ) + end + let(:peer) do + Dependabot::Dependency.new( + name: "opentelemetry-sdk", + version: nil, + requirements: [{ file: "pyproject.toml", requirement: ">=1.25.0", groups: [], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==1.25.0", groups: [], source: nil }], + previous_version: "1.25.0", + package_manager: "uv" + ) + end + let(:dependencies) { [dependency, peer] } + + it "rewrites the requirement of each of them" do + content = updater.send(:updated_pyproject_content_for, pyproject_file) + + expect(content).to include('"opentelemetry-api==1.26.0"') + expect(content).to include('"opentelemetry-sdk>=1.25.0"') + end + + context "when only one of them is a build-system requirement" do + let(:peer) do + Dependabot::Dependency.new( + name: "hatchling", + version: "1.26.0", + requirements: [{ file: "pyproject.toml", requirement: "==1.26.0", groups: ["build-system"], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==1.25.0", groups: ["build-system"], + source: nil }], + previous_version: "1.25.0", + package_manager: "uv" + ) + end + + it "is not build-system only" do + expect(updater.send(:build_system_only_dependency?)).to be(false) + end + end + + context "when only the first of them is a build-system requirement" do + let(:dependency) do + Dependabot::Dependency.new( + name: "hatchling", + version: "1.26.0", + requirements: [{ file: "pyproject.toml", requirement: "==1.26.0", groups: ["build-system"], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==1.25.0", groups: ["build-system"], + source: nil }], + previous_version: "1.25.0", + package_manager: "uv" + ) + end + + it "is not build-system only" do + expect(updater.send(:build_system_only_dependency?)).to be(false) + end + end + + context "when all of them are build-system requirements" do + let(:dependency) do + Dependabot::Dependency.new( + name: "hatchling", + version: "1.26.0", + requirements: [{ file: "pyproject.toml", requirement: "==1.26.0", groups: ["build-system"], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==1.25.0", groups: ["build-system"], + source: nil }], + previous_version: "1.25.0", + package_manager: "uv" + ) + end + let(:peer) do + Dependabot::Dependency.new( + name: "setuptools", + version: "75.0.0", + requirements: [{ file: "pyproject.toml", requirement: "==75.0.0", groups: ["build-system"], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==74.0.0", groups: ["build-system"], + source: nil }], + previous_version: "74.0.0", + package_manager: "uv" + ) + end + + it "is build-system only" do + expect(updater.send(:build_system_only_dependency?)).to be(true) + end + end + + context "when only a later dependency is declared in pyproject.toml" do + let(:dependency) do + Dependabot::Dependency.new( + name: "requests", + version: "2.23.0", + requirements: [{ file: "requirements.txt", requirement: "==2.23.0", groups: [], source: nil }], + previous_requirements: [{ file: "requirements.txt", requirement: "==2.22.0", groups: [], source: nil }], + previous_version: "2.22.0", + package_manager: "uv" + ) + end + + it "still updates the lock file" do + expect(updater.send(:create_or_update_lock_file?)).to be(true) + end + end + end + describe "#replace_dep" do subject(:replace_dep) do updater.send( @@ -1801,6 +2013,37 @@ end end + context "when another package name starts with the dependency name" do + let(:content) do + <<~TOML + [project] + name = "myproject" + dependencies = [ + "boto3-stubs[s3]>=1.34.0", + "boto3>=1.34.0", + ] + TOML + end + let(:dependency) do + Dependabot::Dependency.new( + name: "boto3", + version: "1.35.0", + requirements: [{ file: "pyproject.toml", requirement: "==1.35.0", groups: [], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: ">=1.30.0", groups: [], source: nil }], + previous_version: "1.34.0", + package_manager: "uv" + ) + end + let(:new_req) { { requirement: "==1.35.0" } } + let(:old_req) { { requirement: ">=1.30.0" } } + + it "leaves the longer name alone" do + result = replace_dep + expect(result).to include('"boto3-stubs[s3]>=1.34.0"') + expect(result).to include('"boto3==1.35.0"') + end + end + context "with package names containing dots, underscores, or hyphens (PyPI name normalization)" do let(:dependency) do Dependabot::Dependency.new( diff --git a/uv/spec/dependabot/uv/update_checker/lockstep_resolver_spec.rb b/uv/spec/dependabot/uv/update_checker/lockstep_resolver_spec.rb new file mode 100644 index 00000000000..a6620a34497 --- /dev/null +++ b/uv/spec/dependabot/uv/update_checker/lockstep_resolver_spec.rb @@ -0,0 +1,483 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/dependency" +require "dependabot/dependency_file" +require "dependabot/package/release_cooldown_options" +require "dependabot/requirements_update_strategy" +require "dependabot/uv/update_checker/lockstep_resolver" + +RSpec.describe Dependabot::Uv::UpdateChecker::LockstepResolver do + let(:resolver) do + described_class.new( + dependency: dependency, + dependency_files: dependency_files, + credentials: [], + repo_contents_path: nil, + requirements_update_strategy: Dependabot::RequirementsUpdateStrategy::BumpVersions, + update_cooldown: update_cooldown + ) + end + let(:dependency) { api } + let(:update_cooldown) { nil } + + let(:pyproject) do + Dependabot::DependencyFile.new(name: "pyproject.toml", content: fixture("pyproject_files", "lockstep_pinned.toml")) + end + let(:lockfile) do + Dependabot::DependencyFile.new(name: "uv.lock", content: fixture("uv_locks", "lockstep_pinned.lock")) + end + let(:dependency_files) { [pyproject, lockfile] } + + let(:api) { pinned("opentelemetry-api", "1.25.0") } + let(:sdk) { pinned("opentelemetry-sdk", "1.25.0") } + let(:top_level) { [api, sdk] } + let(:target) { Dependabot::Uv::Version.new("1.26.0") } + + let(:lock_updater) { instance_double(Dependabot::Uv::FileUpdater::LockFileUpdater) } + let(:resolved_lockfile) do + Dependabot::DependencyFile.new(name: "uv.lock", content: bumped_lock(%w(opentelemetry-api opentelemetry-sdk))) + end + let(:conflict) do + Dependabot::UpdateNotPossible.new(%w(opentelemetry-sdk opentelemetry-api)) + end + + def pinned(name, version) + Dependabot::Dependency.new( + name: name, + version: version, + requirements: [{ file: "pyproject.toml", requirement: "==#{version}", groups: [], source: nil }], + package_manager: "uv" + ) + end + + def bumped_lock(names) + names.reduce(fixture("uv_locks", "lockstep_pinned.lock")) do |content, name| + content.gsub("name = \"#{name}\"\nversion = \"1.25.0\"", "name = \"#{name}\"\nversion = \"1.26.0\"") + end + end + + # Stands in for uv: the block gets the names passed to the lock updater and returns its files or raises + def stub_uv + allow(Dependabot::Uv::FileUpdater::LockFileUpdater).to receive(:new) do |args| + updater = instance_double(Dependabot::Uv::FileUpdater::LockFileUpdater) + allow(updater).to receive(:updated_dependency_files) { yield(args[:dependencies].map(&:name)) } + updater + end + end + + before do + allow(Dependabot::Uv::FileParser).to receive(:new) + .and_return(instance_double(Dependabot::Uv::FileParser, parse: top_level)) + allow(Dependabot::Uv::FileUpdater::LockFileUpdater).to receive(:new).and_return(lock_updater) + end + + describe "#neighbours_in_lockfile?" do + it "is true when another top-level dependency depends on it" do + expect(resolver.neighbours_in_lockfile?).to be(true) + end + + context "when the other top-level dependencies are unrelated" do + let(:top_level) { [api, pinned("urllib3", "2.2.3")] } + + it { expect(resolver.neighbours_in_lockfile?).to be(false) } + end + + context "when the dependency itself depends on another top-level dependency" do + let(:dependency) { sdk } + + it { expect(resolver.neighbours_in_lockfile?).to be(true) } + end + end + + describe "#lockstep_conflict?" do + it "rewrites only the dependency's pin and upgrades only it" do + allow(lock_updater).to receive(:updated_dependency_files).and_return([resolved_lockfile]) + + expect(resolver.lockstep_conflict?(target)).to be(false) + expect(Dependabot::Uv::FileUpdater::LockFileUpdater).to have_received(:new).with( + hash_including(upgrade_package_names: ["opentelemetry-api"]) + ) do |args| + expect(args[:dependencies].map(&:name)).to eq(["opentelemetry-api"]) + expect(args[:dependencies].first.requirements.first[:requirement]).to eq("==1.26.0") + end + expect(resolver.rejected_version).to be_nil + end + + it "records the version when uv's conflict names another direct dependency" do + allow(lock_updater).to receive(:updated_dependency_files).and_raise(conflict) + + expect(resolver.lockstep_conflict?(target)).to be(true) + expect(resolver.rejected_version).to eq(target) + end + + it "doesn't count a conflict that names no other direct dependency" do + allow(lock_updater).to receive(:updated_dependency_files).and_raise( + Dependabot::DependencyFileNotResolvable, + "× No solution found when resolving dependencies:\n" \ + "╰─▶ Because the current Python version (3.10.12) does not satisfy Python>=3.12 and " \ + "opentelemetry-api==1.26.0 depends on Python>=3.12, we can conclude that opentelemetry-api==1.26.0 " \ + "cannot be used. And because your project depends on opentelemetry-api==1.26.0, we can conclude " \ + "that your project's requirements are unsatisfiable." + ) + + expect(resolver.lockstep_conflict?(target)).to be(false) + expect(resolver.rejected_version).to be_nil + end + + context "when the only other dependency named is not a peer" do + before { allow(lock_updater).to receive(:updated_dependency_files).and_raise(conflict) } + + context "when it is only a build-system requirement" do + let(:sdk) do + Dependabot::Dependency.new( + name: "opentelemetry-sdk", + version: "1.25.0", + requirements: [{ file: "pyproject.toml", requirement: "==1.25.0", groups: ["build-system"], source: nil }], + package_manager: "uv" + ) + end + + it { expect(resolver.lockstep_conflict?(target)).to be(false) } + end + + context "when it is a local package" do + let(:lockfile) do + content = fixture("uv_locks", "lockstep_pinned.lock").sub( + "name = \"opentelemetry-sdk\"\nversion = \"1.25.0\"\nsource = { registry = \"https://pypi.org/simple\" }", + "name = \"opentelemetry-sdk\"\nversion = \"1.25.0\"\nsource = { editable = \"sdk\" }" + ) + Dependabot::DependencyFile.new(name: "uv.lock", content: content) + end + + it { expect(resolver.lockstep_conflict?(target)).to be(false) } + end + + context "when it has no version" do + let(:sdk) do + Dependabot::Dependency.new( + name: "opentelemetry-sdk", + version: nil, + requirements: [{ file: "pyproject.toml", requirement: ">=1.25.0", groups: [], source: nil }], + package_manager: "uv" + ) + end + + it { expect(resolver.lockstep_conflict?(target)).to be(false) } + end + end + + it "lets other errors through" do + allow(lock_updater).to receive(:updated_dependency_files) + .and_raise(Dependabot::PrivateSourceAuthenticationFailure.new("example.com")) + + expect { resolver.lockstep_conflict?(target) } + .to raise_error(Dependabot::PrivateSourceAuthenticationFailure) + end + + it "reuses its uv run as the first round of the full unlock" do + stub_uv do |names| + raise conflict unless names.include?("opentelemetry-sdk") + + [resolved_lockfile] + end + + resolver.lockstep_conflict?(target) + updates = resolver.updated_dependencies_after_full_unlock(target) + + expect(updates.map(&:name)).to eq(%w(opentelemetry-api opentelemetry-sdk)) + expect(Dependabot::Uv::FileUpdater::LockFileUpdater).to have_received(:new).twice + end + end + + describe "#updated_dependencies_after_full_unlock" do + subject(:updates) { resolver.updated_dependencies_after_full_unlock(target) } + + it "relaxes the peers named in the conflict and returns both, dependency first" do + calls = 0 + allow(lock_updater).to receive(:updated_dependency_files) do + calls += 1 + raise conflict if calls == 1 + + [resolved_lockfile] + end + + expect(updates.map(&:name)).to eq(%w(opentelemetry-api opentelemetry-sdk)) + expect(updates.map(&:version)).to eq(%w(1.26.0 1.26.0)) + expect(updates.last.requirements.first[:requirement]).to eq("==1.26.0") + expect(updates.last.previous_requirements.first[:requirement]).to eq("==1.25.0") + expect(updates.last.previous_version).to eq("1.25.0") + expect(Dependabot::Uv::FileUpdater::LockFileUpdater).to have_received(:new).with( + hash_including(upgrade_package_names: ["opentelemetry-api"]) + ).twice + end + + it "relaxes the peer to its locked version without upgrading it" do + relaxed = nil + calls = 0 + allow(Dependabot::Uv::FileUpdater::LockFileUpdater).to receive(:new) do |args| + relaxed = args[:dependencies].find { |dep| dep.name == "opentelemetry-sdk" } + lock_updater + end + allow(lock_updater).to receive(:updated_dependency_files) do + calls += 1 + raise conflict if calls == 1 + + [resolved_lockfile] + end + + updates + expect(relaxed.requirements.first[:requirement]).to eq(">=1.25.0") + expect(relaxed.version).to be_nil + end + + context "when the peer only appears further down uv's derivation" do + let(:message) do + <<~ERROR + × No solution found when resolving dependencies: + ╰─▶ Because opentelemetry-semantic-conventions==0.46b0 depends on opentelemetry-api==1.25.0 and your + project depends on opentelemetry-api==1.26.0, we can conclude that + opentelemetry-semantic-conventions==0.46b0 cannot be used. + And because opentelemetry-sdk==1.25.0 depends on opentelemetry-semantic-conventions==0.46b0 and + your project depends on opentelemetry-sdk==1.25.0, we can conclude that your project's + requirements are unsatisfiable. + ERROR + end + + it "reads the peer from the uv output behind the conflict" do + relaxed = nil + calls = 0 + allow(Dependabot::Uv::FileUpdater::LockFileUpdater).to receive(:new) do |args| + relaxed ||= args[:dependencies].find { |dep| dep.name == "opentelemetry-sdk" } + lock_updater + end + allow(lock_updater).to receive(:updated_dependency_files) do + calls += 1 + if calls == 1 + begin + raise Dependabot::SharedHelpers::HelperSubprocessFailed.new(message: message, error_context: {}) + rescue StandardError + raise Dependabot::UpdateNotPossible, %w(opentelemetry-semantic-conventions opentelemetry-api) + end + end + + [resolved_lockfile] + end + + expect(updates.map(&:name)).to eq(%w(opentelemetry-api opentelemetry-sdk)) + expect(relaxed.requirements.first[:requirement]).to eq(">=1.25.0") + end + end + + it "relaxes the peers named in an unresolvable uv conflict" do + calls = 0 + allow(lock_updater).to receive(:updated_dependency_files) do + calls += 1 + if calls == 1 + raise Dependabot::DependencyFileNotResolvable, + "× No solution found when resolving dependencies:\n" \ + "╰─▶ Because opentelemetry-sdk==1.25.0 depends on opentelemetry-api==1.25.0 and your project " \ + "depends on opentelemetry-api==1.26.0, we can conclude that your project's requirements are " \ + "unsatisfiable." + end + + [resolved_lockfile] + end + + expect(updates.map(&:name)).to eq(%w(opentelemetry-api opentelemetry-sdk)) + end + + it "relaxes the peers named in an unresolvable conflict printed by uv >= 0.12.14" do + calls = 0 + allow(lock_updater).to receive(:updated_dependency_files) do + calls += 1 + if calls == 1 + raise Dependabot::DependencyFileNotResolvable, + "error: No solution found when resolving dependencies for split (markers: " \ + "python_full_version >= '3.12')\n" \ + " cause: Because opentelemetry-sdk==1.25.0 depends on opentelemetry-api==1.25.0 and your " \ + "project depends on opentelemetry-api==1.26.0, we can conclude that your project and " \ + "opentelemetry-sdk==1.25.0 are incompatible." + end + + [resolved_lockfile] + end + + expect(updates.map(&:name)).to eq(%w(opentelemetry-api opentelemetry-sdk)) + end + + it "lets unresolvable errors that aren't conflicts through" do + allow(lock_updater).to receive(:updated_dependency_files) + .and_raise(Dependabot::DependencyFileNotResolvable.new("Failed to build foo")) + + expect { updates }.to raise_error(Dependabot::DependencyFileNotResolvable, "Failed to build foo") + end + + it "gives up when the conflict names no new peer" do + stub_uv do |names| + raise conflict unless names.include?("opentelemetry-sdk") + + raise Dependabot::UpdateNotPossible, %w(opentelemetry-sdk opentelemetry-api) + end + + expect(updates).to be_nil + expect(Dependabot::Uv::FileUpdater::LockFileUpdater).to have_received(:new).twice + end + + it "gives up when no peer moved" do + calls = 0 + allow(lock_updater).to receive(:updated_dependency_files) do + calls += 1 + raise conflict if calls == 1 + + [Dependabot::DependencyFile.new(name: "uv.lock", content: bumped_lock(%w(opentelemetry-api)))] + end + + expect(updates).to be_nil + end + + context "when uv forks the peer onto several versions" do + let(:forked_lockfile) do + content = bumped_lock(%w(opentelemetry-api opentelemetry-sdk)) + sdk_block = content[/\[\[package\]\]\nname = "opentelemetry-sdk".*?(?=\n\[\[package\]\])/m] + Dependabot::DependencyFile.new( + name: "uv.lock", + content: content + "\n" + sdk_block.sub('version = "1.26.0"', 'version = "1.25.0"') + "\n" + ) + end + + it "gives up, since a single pin can't express it" do + stub_uv do |names| + raise conflict unless names.include?("opentelemetry-sdk") + + [forked_lockfile] + end + + expect(updates).to be_nil + end + end + + context "when the dependency is the one depending on its peer" do + let(:dependency) { sdk } + + it "moves the peer it depends on, dependency first" do + stub_uv do |names| + raise Dependabot::UpdateNotPossible, %w(opentelemetry-sdk opentelemetry-api) unless + names.include?("opentelemetry-api") + + [resolved_lockfile] + end + + expect(updates.map(&:name)).to eq(%w(opentelemetry-sdk opentelemetry-api)) + expect(updates.map(&:version)).to eq(%w(1.26.0 1.26.0)) + end + end + + context "when each round's conflict names another peer" do + let(:semantic_conventions) { pinned("opentelemetry-semantic-conventions", "0.46b0") } + let(:top_level) { [api, sdk, semantic_conventions] } + let(:all_bumped) do + content = bumped_lock(%w(opentelemetry-api opentelemetry-sdk)).sub( + "name = \"opentelemetry-semantic-conventions\"\nversion = \"0.46b0\"", + "name = \"opentelemetry-semantic-conventions\"\nversion = \"0.47b0\"" + ) + Dependabot::DependencyFile.new(name: "uv.lock", content: content) + end + + it "relaxes them all and returns every moved peer, sorted by name" do + rounds = [] + stub_uv do |names| + rounds << names + case names.length + when 1 then raise Dependabot::UpdateNotPossible, %w(opentelemetry-semantic-conventions opentelemetry-api) + when 2 then raise Dependabot::UpdateNotPossible, %w(opentelemetry-sdk opentelemetry-api) + else [all_bumped] + end + end + + expect(updates.map(&:name)) + .to eq(%w(opentelemetry-api opentelemetry-sdk opentelemetry-semantic-conventions)) + expect(updates.map(&:version)).to eq(%w(1.26.0 1.26.0 0.47b0)) + expect(rounds.last).to eq(%w(opentelemetry-api opentelemetry-semantic-conventions opentelemetry-sdk)) + end + end + + context "with a cooldown" do + let(:update_cooldown) { Dependabot::Package::ReleaseCooldownOptions.new(default_days: 7) } + let(:latest_version_finder) do + instance_double(Dependabot::Uv::UpdateChecker::LatestVersionFinder, latest_version: latest_allowed) + end + + before do + allow(Dependabot::Uv::UpdateChecker::LatestVersionFinder).to receive(:new).and_return(latest_version_finder) + stub_uv do |names| + raise conflict unless names.include?("opentelemetry-sdk") + + [resolved_lockfile] + end + end + + context "when the peer's new version is still cooling down" do + let(:latest_allowed) { Dependabot::Uv::Version.new("1.25.0") } + + it { expect(updates).to be_nil } + end + + context "when the peer's new version is out of the cooldown window" do + let(:latest_allowed) { Dependabot::Uv::Version.new("1.26.0") } + + it "moves the peer" do + expect(updates.map(&:name)).to eq(%w(opentelemetry-api opentelemetry-sdk)) + expect(Dependabot::Uv::UpdateChecker::LatestVersionFinder).to have_received(:new).with( + hash_including(cooldown_options: update_cooldown, ignored_versions: []) + ) { |args| expect(args[:dependency].name).to eq("opentelemetry-sdk") } + end + end + end + + context "when the peer is locked at several versions" do + let(:lockfile) do + content = fixture("uv_locks", "lockstep_pinned.lock") + sdk_block = content[/\[\[package\]\]\nname = "opentelemetry-sdk".*?(?=\n\[\[package\]\])/m] + Dependabot::DependencyFile.new( + name: "uv.lock", + content: content + "\n" + sdk_block.sub('version = "1.25.0"', 'version = "1.24.0"') + "\n" + ) + end + + it "does not treat it as a peer" do + stub_uv do |names| + raise conflict unless names.include?("opentelemetry-sdk") + + [resolved_lockfile] + end + + expect(updates).to be_nil + expect(Dependabot::Uv::FileUpdater::LockFileUpdater).to have_received(:new).once + end + end + + context "when the peer is declared twice with different extras" do + let(:top_level) { [api, sdk, pinned("opentelemetry-sdk[extra]", "1.25.0")] } + + it "relaxes it once" do + relaxed_names = [] + calls = 0 + allow(Dependabot::Uv::FileUpdater::LockFileUpdater).to receive(:new) do |args| + relaxed_names = args[:dependencies].drop(1).map(&:name) + lock_updater + end + allow(lock_updater).to receive(:updated_dependency_files) do + calls += 1 + raise conflict if calls == 1 + + [resolved_lockfile] + end + + updates + expect(relaxed_names).to eq(["opentelemetry-sdk"]) + end + end + end +end diff --git a/uv/spec/dependabot/uv/update_checker_spec.rb b/uv/spec/dependabot/uv/update_checker_spec.rb index 96f0cd4313e..36837b6c938 100644 --- a/uv/spec/dependabot/uv/update_checker_spec.rb +++ b/uv/spec/dependabot/uv/update_checker_spec.rb @@ -8,6 +8,7 @@ require "dependabot/uv/file_updater" require "dependabot/uv/update_checker" require "dependabot/requirements_update_strategy" +require "dependabot/security_advisory" require_common_spec "update_checkers/shared_examples_for_update_checkers" RSpec.describe Dependabot::Uv::UpdateChecker do @@ -1164,4 +1165,268 @@ end end end + + describe "lockstep-pinned dependencies" do + let(:dependency_files) do + [ + Dependabot::DependencyFile.new( + name: "pyproject.toml", + content: fixture("pyproject_files", "lockstep_pinned.toml") + ), + Dependabot::DependencyFile.new(name: "uv.lock", content: fixture("uv_locks", "lockstep_pinned.lock")) + ] + end + let(:dependency_name) { "opentelemetry-api" } + let(:dependency_version) { "1.25.0" } + let(:dependency_requirements) do + [{ file: "pyproject.toml", requirement: "==1.25.0", groups: [], source: nil }] + end + let(:pypi_url) { "https://pypi.org/simple/opentelemetry-api/" } + let(:pypi_response) do + <<~HTML + + opentelemetry_api-1.25.0-py3-none-any.whl
+ opentelemetry_api-1.26.0-py3-none-any.whl
+ + HTML + end + let(:lockstep_resolver) do + instance_double( + Dependabot::Uv::UpdateChecker::LockstepResolver, + neighbours_in_lockfile?: true, + rejected_version: Dependabot::Uv::Version.new("1.26.0") + ) + end + let(:both_bumped) do + %w(opentelemetry-api opentelemetry-sdk).map do |name| + Dependabot::Dependency.new( + name: name, + version: "1.26.0", + previous_version: "1.25.0", + requirements: [{ file: "pyproject.toml", requirement: "==1.26.0", groups: [], source: nil }], + previous_requirements: [{ file: "pyproject.toml", requirement: "==1.25.0", groups: [], source: nil }], + package_manager: "uv" + ) + end + end + + before do + stub_request(:get, "https://pypi.org/pypi/lockstep/json/").to_return(status: 404) + allow(Dependabot::Uv::UpdateChecker::LockstepResolver).to receive(:new).and_return(lockstep_resolver) + allow(lockstep_resolver).to receive_messages( + lockstep_conflict?: true, + updated_dependencies_after_full_unlock: both_bumped + ) + end + + context "when the experiment is off" do + it "behaves as before" do + expect(checker.can_update?(requirements_to_unlock: :own)).to be(true) + expect(Dependabot::Uv::UpdateChecker::LockstepResolver).not_to have_received(:new) + end + end + + context "when the experiment is on" do + before { Dependabot::Experiments.register(:uv_lockstep_full_unlock, true) } + + it "can't update on its own" do + expect(checker.can_update?(requirements_to_unlock: :own)).to be(false) + end + + context "when uv finds no lockstep conflict" do + before { allow(lockstep_resolver).to receive(:lockstep_conflict?).and_return(false) } + + it "keeps the own update" do + expect(checker.can_update?(requirements_to_unlock: :own)).to be(true) + end + end + + context "when no other top-level dependency is linked to it" do + before { allow(lockstep_resolver).to receive(:neighbours_in_lockfile?).and_return(false) } + + it "doesn't probe with uv" do + expect(checker.can_update?(requirements_to_unlock: :own)).to be(true) + expect(lockstep_resolver).not_to have_received(:lockstep_conflict?) + end + end + + context "when the peers can't be moved either" do + before { allow(lockstep_resolver).to receive(:updated_dependencies_after_full_unlock).and_return(nil) } + + it "can't update" do + expect(checker.can_update?(requirements_to_unlock: :all)).to be(false) + end + end + + context "when the dependency is vulnerable" do + let(:security_advisories) do + [Dependabot::SecurityAdvisory.new( + dependency_name: "opentelemetry-api", + package_manager: "uv", + vulnerable_versions: ["<1.26.0"] + )] + end + let(:pypi_response) do + <<~HTML + + opentelemetry_api-1.25.0-py3-none-any.whl
+ opentelemetry_api-1.26.0-py3-none-any.whl
+ opentelemetry_api-1.27.0-py3-none-any.whl
+ + HTML + end + + before do + rejected = nil + allow(lockstep_resolver).to receive(:lockstep_conflict?) do |version| + rejected = version + true + end + allow(lockstep_resolver).to receive(:rejected_version) { rejected } + end + + it "probes and fully unlocks to the lowest fixed version, not the latest" do + checker.can_update?(requirements_to_unlock: :own) + checker.can_update?(requirements_to_unlock: :all) + + expect(lockstep_resolver).to have_received(:lockstep_conflict?) + .with(Dependabot::Uv::Version.new("1.26.0")) + expect(lockstep_resolver).not_to have_received(:lockstep_conflict?) + .with(Dependabot::Uv::Version.new("1.27.0")) + expect(lockstep_resolver).to have_received(:updated_dependencies_after_full_unlock) + .with(Dependabot::Uv::Version.new("1.26.0")) + end + end + + context "with the real resolver and lock file updater, and only uv stubbed" do + let(:uv_commands) { [] } + let(:probed_pyprojects) { [] } + let(:uv_conflict) do + <<~ERROR + × No solution found when resolving dependencies for split (markers: + │ python_full_version >= '3.12'): + ╰─▶ Because opentelemetry-sdk==1.25.0 depends on opentelemetry-api==1.25.0 + and your project depends on opentelemetry-api==1.26.0, we can conclude + that your project and opentelemetry-sdk==1.25.0 are incompatible. + And because your project depends on opentelemetry-sdk==1.25.0, we can + conclude that your project's requirements are unsatisfiable. + ERROR + end + + before do + allow(Dependabot::Uv::UpdateChecker::LockstepResolver).to receive(:new).and_call_original + allow(Dependabot::Uv::FileUpdater::LockFileUpdater).to receive(:new).and_call_original + allow(Dependabot::Uv::LanguageVersionManager).to receive(:new).and_return( + instance_double( + Dependabot::Uv::LanguageVersionManager, + install_required_python: nil, + python_version: "3.11.0", + python_major_minor: "3.11" + ) + ) + allow(Dependabot::SharedHelpers).to receive(:with_git_configured).and_yield + allow(Dependabot::SharedHelpers).to receive(:run_shell_command) do |command, **_args| + next "" if command.start_with?("pyenv local ") + + uv_commands << command + pyproject = File.read("pyproject.toml") + probed_pyprojects << pyproject + unless pyproject.include?("opentelemetry-sdk>=1.25.0") + raise Dependabot::SharedHelpers::HelperSubprocessFailed.new(message: uv_conflict, error_context: {}) + end + + File.write("uv.lock", File.read("uv.lock").gsub('version = "1.25.0"', 'version = "1.26.0"')) + "" + end + end + + it "moves the peer only through its relaxed pin, in two uv runs" do + expect(checker.can_update?(requirements_to_unlock: :own)).to be(false) + expect(checker.can_update?(requirements_to_unlock: :all)).to be(true) + + updated = checker.updated_dependencies(requirements_to_unlock: :all) + expect(updated.map(&:name)).to eq(%w(opentelemetry-api opentelemetry-sdk)) + expect(updated.map(&:version)).to eq(%w(1.26.0 1.26.0)) + expect(updated.map { |dep| dep.requirements.first[:requirement] }).to eq(%w(==1.26.0 ==1.26.0)) + + expect(Dependabot::Uv::FileUpdater::LockFileUpdater).to have_received(:new).twice + expect(uv_commands.size).to eq(2) + expect(probed_pyprojects).to all(include('"opentelemetry-api==1.26.0"')) + expect(probed_pyprojects.last).to include('"opentelemetry-sdk>=1.25.0"') + expect(uv_commands).to all(include("uv lock --upgrade-package opentelemetry-api==1.26.0 ")) + expect(uv_commands.grep(/--upgrade-package opentelemetry-sdk/)).to be_empty + end + + context "when uv >= 0.12.14 prints the conflict" do + # generated with uv 0.12.20 + let(:uv_conflict) do + <<~ERROR + error: No solution found when resolving dependencies for split (markers: python_full_version >= '3.12') + cause: Because opentelemetry-sdk==1.25.0 depends on opentelemetry-api==1.25.0 and your project depends on opentelemetry-api==1.26.0, we can conclude that your project and opentelemetry-sdk==1.25.0 are incompatible. + And because your project depends on opentelemetry-sdk==1.25.0, we can conclude that your project's requirements are unsatisfiable. + ERROR + end + + it "still moves the peer with it" do + expect(checker.can_update?(requirements_to_unlock: :own)).to be(false) + expect(checker.can_update?(requirements_to_unlock: :all)).to be(true) + + updated = checker.updated_dependencies(requirements_to_unlock: :all) + expect(updated.map(&:name)).to eq(%w(opentelemetry-api opentelemetry-sdk)) + expect(updated.map(&:version)).to eq(%w(1.26.0 1.26.0)) + end + end + + context "when uv fails for a reason that names no other direct dependency" do + let(:uv_conflict) do + <<~ERROR + × No solution found when resolving dependencies: + ╰─▶ Because the current Python version (3.10.12) does not satisfy Python>=3.12 and + opentelemetry-api==1.26.0 depends on Python>=3.12, we can conclude that + opentelemetry-api==1.26.0 cannot be used. + And because your project depends on opentelemetry-api==1.26.0, we can conclude + that your project's requirements are unsatisfiable. + ERROR + end + + it "keeps the own update, so the file updater reports the error as before" do + expect(checker.can_update?(requirements_to_unlock: :own)).to be(true) + end + end + + context "when uv >= 0.12.14 fails for a reason that names no other direct dependency" do + let(:uv_conflict) do + <<~ERROR + error: No solution found when resolving dependencies + cause: Because the current Python version (3.10.12) does not satisfy Python>=3.12 and opentelemetry-api==1.26.0 depends on Python>=3.12, we can conclude that opentelemetry-api==1.26.0 cannot be used. + And because your project depends on opentelemetry-api==1.26.0, we can conclude that your project's requirements are unsatisfiable. + ERROR + end + + it "keeps the own update, so the file updater reports the error as before" do + expect(checker.can_update?(requirements_to_unlock: :own)).to be(true) + end + end + + context "when asked about the peer instead" do + let(:dependency_name) { "opentelemetry-sdk" } + let(:pypi_url) { "https://pypi.org/simple/opentelemetry-sdk/" } + let(:pypi_response) do + <<~HTML + + opentelemetry_sdk-1.25.0-py3-none-any.whl
+ opentelemetry_sdk-1.26.0-py3-none-any.whl
+ + HTML + end + + it "also reports that it can't move on its own" do + # update_all_versions#peer_dependency_should_update_instead? relies on this + expect(checker.can_update?(requirements_to_unlock: :own)).to be(false) + expect(uv_commands).to all(include("uv lock --upgrade-package opentelemetry-sdk==1.26.0 ")) + end + end + end + end + end end diff --git a/uv/spec/fixtures/pyproject_files/lockstep_pinned.toml b/uv/spec/fixtures/pyproject_files/lockstep_pinned.toml new file mode 100644 index 00000000000..db448e17514 --- /dev/null +++ b/uv/spec/fixtures/pyproject_files/lockstep_pinned.toml @@ -0,0 +1,8 @@ +[project] +name = "lockstep" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "opentelemetry-api==1.25.0", + "opentelemetry-sdk==1.25.0", +] diff --git a/uv/spec/fixtures/uv_locks/lockstep_pinned.lock b/uv/spec/fixtures/uv_locks/lockstep_pinned.lock new file mode 100644 index 00000000000..c81b8a78340 --- /dev/null +++ b/uv/spec/fixtures/uv_locks/lockstep_pinned.lock @@ -0,0 +1,219 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "deprecated" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/9c/16649913bf14c73e0a9453782e148362ff2657067deff6aa9c7ebcddcc31/deprecated-3.0.0.tar.gz", hash = "sha256:16850204d3a1e6bb0acd06bff48d96e8b0a0d25d1c52f71705405a0f4894192d", size = 166912, upload-time = "2026-09-26T13:58:10.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ae/676feae8e4644a6d7169951a97f61c56f416c73f67bf1761f2461d75cc81/deprecated-3.0.0-py3-none-any.whl", hash = "sha256:58204cf4a7f6270d547af5c278ee7a6bb56045a4b3d8441a1cd11660f41b7939", size = 21912, upload-time = "2026-09-26T13:58:09.458Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/c4e6078d21fc4fa56300a241b87eae76766aa380a23fc450fc85bb7bf547/importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2", size = 52120, upload-time = "2024-03-20T19:51:32.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", size = 24409, upload-time = "2024-03-20T19:51:30.241Z" }, +] + +[[package]] +name = "lockstep" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-api", specifier = "==1.25.0" }, + { name = "opentelemetry-sdk", specifier = "==1.25.0" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "deprecated", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/0d/10357006dc10fc65f7c7b46c18232e466e355f9e606ac461cfc7193b4cbe/opentelemetry_api-1.25.0.tar.gz", hash = "sha256:77c4985f62f2614e42ce77ee4c9da5fa5f0bc1e1821085e9a47533a9323ae869", size = 60383, upload-time = "2024-05-31T01:40:38.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b2/4bc5e52c9a23df0ac17dbb23923e609a8269cd67000a712b4f5bcfae1490/opentelemetry_api-1.25.0-py3-none-any.whl", hash = "sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737", size = 59910, upload-time = "2024-05-31T01:40:00.911Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/3c/77076b77f1d73141adc119f62370ec9456ef314ba0b4e7072e3775c36ef7/opentelemetry_sdk-1.25.0.tar.gz", hash = "sha256:ce7fc319c57707ef5bf8b74fb9f8ebdb8bfafbe11898410e0d2a761d08a98ec7", size = 141042, upload-time = "2024-05-31T01:40:53.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/b2/729a959a8aa032bce246c791f977161099ab60fb0188408ccec1bf283b00/opentelemetry_sdk-1.25.0-py3-none-any.whl", hash = "sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9", size = 107028, upload-time = "2024-05-31T01:40:33.281Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.46b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/ea/a4a5277247b3d2ed2e23a58b0d509c2eafa4ebb56038ba5b23c0f9ea6242/opentelemetry_semantic_conventions-0.46b0.tar.gz", hash = "sha256:fbc982ecbb6a6e90869b15c1673be90bd18c8a56ff1cffc0864e38e2edffaefa", size = 80198, upload-time = "2024-05-31T01:40:54.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/41/28dae1ec1fe0151373f06bd06d9170ca14b52d5b3a6c2dc55f85bc219619/opentelemetry_semantic_conventions-0.46b0-py3-none-any.whl", hash = "sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07", size = 130549, upload-time = "2024-05-31T01:40:35.348Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "wrapt" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/d2/a254a26d8ceaea87e0eee2e89fcfe53ddc1858418647493bb2937549ab6f/wrapt-2.5.0.tar.gz", hash = "sha256:c48cdb6c904dca76d9915a579e4a5fab6b0c25f650c1019ce78a78effaf7a345", size = 183288, upload-time = "2026-09-27T01:41:56.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/23/37e2276e520ae569af851ebffaf03476e6984cd3ff298234ecd6920f7c4e/wrapt-2.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0345d4c1f7aa5a27075d28a7f0e9ed386729198045f1f08b47f8320d6bbda23", size = 104967, upload-time = "2026-09-27T01:39:31.258Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d3/a75a67fb1c9e3164eec4d31c2da3c606895d1ae71f74045eb70ab591fe53/wrapt-2.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aae2f5f4c77335a39ebe5a1c77d5519f6cefefc7dbff50bd551e3771d927e3fc", size = 105434, upload-time = "2026-09-27T01:39:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e1/6f46c822f6657a69c8fef5d0824e0b5287d27b76d3769b501770b917fbb8/wrapt-2.5.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b35ef7379323a149a6398f6261248bf48b61666210bd69e2ab24a9a9afdedc0", size = 231084, upload-time = "2026-09-27T01:39:34.363Z" }, + { url = "https://files.pythonhosted.org/packages/81/49/ee77b33964f83ee5624dbccfa939356ca84092ad30a211681095985a5b7e/wrapt-2.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2af7ff9553c492f684a41d903ec41e37bf6267ee3206c17518a349201cae46", size = 232732, upload-time = "2026-09-27T01:39:35.604Z" }, + { url = "https://files.pythonhosted.org/packages/55/a7/e0c0ca927a03d164927001677530bba68e6fe6b267584dda26cbeba5a2dc/wrapt-2.5.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:734ea79e4707751fb7cc4f716123b2115cd16ed0c4926e803540aece22e3ba67", size = 221110, upload-time = "2026-09-27T01:39:36.877Z" }, + { url = "https://files.pythonhosted.org/packages/78/b0/0fe1d3cf811b0135593c8f91d0bb57ff2576807e37eff2a7aa4e3a24d1cc/wrapt-2.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd22ef8690ca302425f26d4c3e8b24f1284d4351d20e49ce21f9c0dd58d64d", size = 230627, upload-time = "2026-09-27T01:39:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fc/312410d55792dccebe489771fa36a35a71df17580568d6c9685e74102ca6/wrapt-2.5.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:439523506fd0d9af4f76d75f2e46aa4979e164fecba6890859326ff06edbaf9c", size = 218491, upload-time = "2026-09-27T01:39:39.716Z" }, + { url = "https://files.pythonhosted.org/packages/f8/90/a647f92c48c981ce198fe59bfd62c2113ca58e8557bcb8f05149cac93f5d/wrapt-2.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94ebe745d1b0ebd3af91e22c38961616d32a228bdf9c326e7d4a6007a623d48e", size = 220505, upload-time = "2026-09-27T01:39:41.215Z" }, + { url = "https://files.pythonhosted.org/packages/23/34/2f5bf610f5629f68ca71188f8c33db3422da469e05d9d719b84c4304ddf0/wrapt-2.5.0-cp310-cp310-win32.whl", hash = "sha256:5375ff1d2159e2ef847449e3dd2329441423d0a3d895d123273a67095fe7ca0c", size = 100329, upload-time = "2026-09-27T01:39:42.619Z" }, + { url = "https://files.pythonhosted.org/packages/87/c1/c9d72d5f648798c680d68ecaa691b55f7a54b4b9c1f0cb373502b975a01f/wrapt-2.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:513dd1f4a1f91030d5656d9f4b3af8aa490d4db1eb53481e6846b8a5ee7acfcd", size = 105398, upload-time = "2026-09-27T01:39:43.851Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8f/99de0a11ed51906b3a81a5e7ba7ab7d2d39ca4882a31afcb97c70ab9de60/wrapt-2.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:c3dfb16e047c912e1a06bfbc45da752219f474897be85f55b8069f7936b08cc0", size = 102622, upload-time = "2026-09-27T01:39:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/7c23187af053bfa54c99964f87b94b96cea76b78f019b06e42fa86752e5d/wrapt-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:57fa1a3fd1279b3ca7655b943ad61d298f2a2464a4cdca7ff298058e408322f9", size = 105327, upload-time = "2026-09-27T01:39:46.242Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/6f7f880207b2d74162a44b35e41217d999fd4af48338afb356c650f89a07/wrapt-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63e58f96849f622ce769dcd705f83c7445cd9829bce1dae00e78bb031aec8096", size = 105501, upload-time = "2026-09-27T01:39:47.592Z" }, + { url = "https://files.pythonhosted.org/packages/6f/19/b0b7e7cf1a5499a25bd5260b38deb3b17aa9ca7b268ef29e546dcb683aa7/wrapt-2.5.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fd91203e156d610ecb28b9ccd7b764af7a7b38662d7c163090babab0d10def0c", size = 239849, upload-time = "2026-09-27T01:39:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/7d/cd/b6cce206889497626f9b8b9d2ecb74324287d853bcca13c8bfab9b99a981/wrapt-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff909b934b1958e31784d412abab5cbb0709fdbc01c86f22965e1d15331371ba", size = 241355, upload-time = "2026-09-27T01:39:50.368Z" }, + { url = "https://files.pythonhosted.org/packages/70/6b/11b3b25915bfed1b47b7486a306602f3c3698b5a6b73b05bbd59bd891650/wrapt-2.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:148052fc55013930217f531c6978e918ab210a12bd73cc9bd6de661a7adaf620", size = 226349, upload-time = "2026-09-27T01:39:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/f6/97/9901b407cacfd7b3efca60f808713294e5758dda023586fe201fe203d134/wrapt-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d4885c5625c9d2dcb49458700851574e9d0ea046c7a265526e990282f8ae8e", size = 239003, upload-time = "2026-09-27T01:39:53.01Z" }, + { url = "https://files.pythonhosted.org/packages/5c/68/b7883fe1c6b445df44e32a2a3a19a544a3a73749c5064a539c10f9f730a4/wrapt-2.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:672dd1bab4256311db1520b1b50e7a10cbaeaae2b0ac6bc5d858cc387ee605a2", size = 223603, upload-time = "2026-09-27T01:39:54.606Z" }, + { url = "https://files.pythonhosted.org/packages/f7/05/f9d527f0da33b901684ad326b6b9fc07593013f9594b9db4ebb0ddc1e127/wrapt-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c3a78a3161b3a9bf07725822fd24d379c1f3f161b6db49166c4131096ea73b4c", size = 229164, upload-time = "2026-09-27T01:39:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e0/4a05ad93a003272619cd98dbfbfb7b45a9c7d1041215473d789d9c6ce8ed/wrapt-2.5.0-cp311-cp311-win32.whl", hash = "sha256:0810e060e58f7960405172ad21080df8e7335841c9fe97417bd7d3f05af24f90", size = 100422, upload-time = "2026-09-27T01:39:57.216Z" }, + { url = "https://files.pythonhosted.org/packages/1e/96/5dcc944f39724e5df8a51f9095e856463df231764d503a7a935503eebeb9/wrapt-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:99f8ea48f14a71c5e2df8763e9a490e8af63096dcd67755b7bab0a4b74fc7cd7", size = 105616, upload-time = "2026-09-27T01:39:58.43Z" }, + { url = "https://files.pythonhosted.org/packages/a3/91/c927dac776c8939616ce865efa4bf3e62c933393528487e8fde981aad356/wrapt-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2ac82ef59ee05e259902bc7cf73dee5e6397845e8ccdc376d9d25536b59a877c", size = 102562, upload-time = "2026-09-27T01:39:59.672Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/44589f9b34160280a1fbccbcf206b18df034a1b3e9584336d3a7f039a33c/wrapt-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b898caea081303006decc562c7fca5126f7c96507e78dd8f1ae3285dfa50ddc7", size = 106192, upload-time = "2026-09-27T01:40:00.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/94db3ba2e9528400e4173cbb67a56822a4644d4c2b319feb0cc638094b36/wrapt-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8837fbe708cb9d8a2d32a37dee836d24a531f02560db26418e2b181986fa21cb", size = 105828, upload-time = "2026-09-27T01:40:02.494Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/3ce67af67525c0068680637c0eba823ce2f0fc6eff2d1779cab656fb8c3d/wrapt-2.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cabb9c17ab79b2549d1f23b36f436473ad9253ef53995a817feba26fae69d5b", size = 250177, upload-time = "2026-09-27T01:40:03.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3b/262b2c3c38aca6fa32cfea6407dbb346ce26ff788179b98137b3b3f3d8ca/wrapt-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6761765cc520ff9616fb035c02a85d1d744f7f70edd4649718fd0d09c589eacf", size = 250332, upload-time = "2026-09-27T01:40:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ec/d54d273a2223d1964ecd5b5954aca18e8fbf2c5d8471e60ef3cbaaed9325/wrapt-2.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a145a7826eddea3eb5814903f98f93756042b919bb5305544cb1331daa2705b1", size = 229600, upload-time = "2026-09-27T01:40:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/8e/88/33c75ac47b13b0bf77b44d96d02b1d0a2e6db66180909bacce86ef8fcd8e/wrapt-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a9ee62a970075738909909bdbef3a7da9f7ae03dfca584db283547a29503b56", size = 246890, upload-time = "2026-09-27T01:40:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/c3a3a158801e5bee7a0d9aa3561a127bd80f166b02b7b520a39e45d8f9b9/wrapt-2.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:691671ea05684f921ffc2e935fd3f9311c1795a10fbbfa006b46269733668f66", size = 226463, upload-time = "2026-09-27T01:40:09.206Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/4c48ba51698a87e2e8299fc04856bbef391cb725f195bc93f0bc6196e1f2/wrapt-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e716f47c7f61e11709d3c0904213c94fc22999abf0c41461276cef886c1e8b4d", size = 238583, upload-time = "2026-09-27T01:40:10.631Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/077835618ed96b131730f74301a7236efd841ec2f341afe36303389b56ef/wrapt-2.5.0-cp312-cp312-win32.whl", hash = "sha256:5421acb5c363a9bc959122a8645e3f1f42010c932dc53885b11a5ff5b5a6d730", size = 100221, upload-time = "2026-09-27T01:40:11.91Z" }, + { url = "https://files.pythonhosted.org/packages/96/9f/9e56db2a3492275be809082c6f63a7a063f52b3e1812aae1eb03ec33f64c/wrapt-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ab45839c912777e2738fed369636589c8b2a6d9c44ca56de0fd0814581d467c2", size = 105786, upload-time = "2026-09-27T01:40:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e4/e37ff75e5254564aa50e13bc4313383d02d5ec78f5bd3b9b1994dd7052d5/wrapt-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce4cab32c37ef71e69cf88f909b7febd0dd79543e5ae3650e2b874e0f3d3b975", size = 102937, upload-time = "2026-09-27T01:40:14.534Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4b/cc7bb5668f7ddc0e73e236e96a0c06cab8fddfca9c53538c9dffac62db6f/wrapt-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b312b3cc87951faaed3cfef984d768ee8bee7f935d9cc929aaa9946b0b96a98c", size = 105808, upload-time = "2026-09-27T01:40:15.826Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/5d15ef0e2f42d5f084930dc4863e6e52c160c28301c8780aae170c58421c/wrapt-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c57ddae24cf72eb6bd18112638a987cafe6109d90f2df111e6934362cc03ac1a", size = 105931, upload-time = "2026-09-27T01:40:17.136Z" }, + { url = "https://files.pythonhosted.org/packages/1e/02/c7174e78b0c38bb279b2d3c25a6bd7fb9d3b0200c3e5a8fad084e7cc3e85/wrapt-2.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b95a6eca3b927853529eea958310563c83140ae8451dd5dc4399c7da385dc4f3", size = 248259, upload-time = "2026-09-27T01:40:18.446Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f9/47ae1d7ef325c3f6c81ae3c1fb4a3fef9d98c8025ed676c0bfc1550903ce/wrapt-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6058e12e9caa33468f9a36fb88c15a4bb30a479f997b37834b83abdbf062f264", size = 248224, upload-time = "2026-09-27T01:40:19.713Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/a394448bcbbaf8e5a3f856520edbbb1b92fc42061def56284c9083f3ac87/wrapt-2.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d245ac03f5ae77f1eea6eb19edd9e778c2f772490c20496c2f1cd3a102ee1b6", size = 227137, upload-time = "2026-09-27T01:40:21.359Z" }, + { url = "https://files.pythonhosted.org/packages/41/45/fc252bda5aa1ca01bc838d3b108778e786a2a13d0c52fd17c5f6179aa246/wrapt-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4ef4935962f7029b2058a99f1a47ccbffc3be919dddb3becb6c2c48eac3d9f0", size = 244710, upload-time = "2026-09-27T01:40:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/527d1bde7371dcc2c378d88c97de03b121b486fb4cd3dbb399bc332c0676/wrapt-2.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f12e80c3089ebc03727d368f8205b811b5af2cd4a72b5e4cac75e901dd316e39", size = 225053, upload-time = "2026-09-27T01:40:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/2b823fded8c3b815408c58633929812eacd29d824fe57548b7868c4ee422/wrapt-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a346408f19b6d589bf029f25f65c0b4cdeed6302ef8f40da4e5d1552d22dc037", size = 236884, upload-time = "2026-09-27T01:40:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/00/d7/5d185c1193b073a0bf4cbe862b5d31f81067eddc39eff30ae632f346563d/wrapt-2.5.0-cp313-cp313-win32.whl", hash = "sha256:79e68f0fd7d381b9bbd71776f602a2d5440d4d2077459128e02fd6607465422c", size = 100198, upload-time = "2026-09-27T01:40:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9a/51d95640e01d0ebdd04a7223755f076e4936b0c124ce99bb01a12b53e66c/wrapt-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:77f0a74ff6f6cf89f5b673a732d5afe1911a6e6b1c017260836fdfdf85518dc1", size = 105610, upload-time = "2026-09-27T01:40:28.465Z" }, + { url = "https://files.pythonhosted.org/packages/67/52/183d5ce7c2a9391774e6a623be6ae564351545713ec9a3693528f89c8e85/wrapt-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:b620d7559b6b2197c5730332fab0867ecf1c8cb74d45533ebbcbcad1eacf4616", size = 102641, upload-time = "2026-09-27T01:40:30Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/0009086ab8f2d5fb32405ef49fdd11104ce40f69ae9f4cdfba8326462816/wrapt-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:65f2ee406dc592a5b22a7dc6abac13e8a3e8de4b2ecf5dc3c22937865496e4b6", size = 106182, upload-time = "2026-09-27T01:40:31.503Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/8f38339a4c55a42df00296dcf6ad50598d2280049f7a6ffa525e9a1f66d1/wrapt-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d75d6366203c8d025c1a74bae0b565952187ddae79bd5c7bf10687652a56f020", size = 106123, upload-time = "2026-09-27T01:40:32.927Z" }, + { url = "https://files.pythonhosted.org/packages/23/38/285b433121d73c7a447b5b82d93c91dc3330f33ab5853975f34551e0c773/wrapt-2.5.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b640460f0ffb346b192686bd6fac5589e35a6c6640501c59a9fb6e82b0dd6bd8", size = 251245, upload-time = "2026-09-27T01:40:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/3f63f4637e89484c1839a9ba3aedda5b2912e7ce12617034c6bd49752cfa/wrapt-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f17c5a3836397bf59fd57b0e5b0dc42969b1daa70d31aa361c6e13cbf138b5a", size = 250483, upload-time = "2026-09-27T01:40:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cd/f24ee96016da222dbb921cfb22e2beb5ca189a7b730ef49e8bb106b49449/wrapt-2.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4343880acd72e74233baf092285aaaf4306244e31d7601828bd2600316027df0", size = 231341, upload-time = "2026-09-27T01:40:37.295Z" }, + { url = "https://files.pythonhosted.org/packages/23/eb/c9b180124271e494f615a130f966be56143e3e26e87706bc28582f94bc09/wrapt-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fdc79c0045d6bb1603c109127145245cafe888588888444e1e37fbeadbac3", size = 247736, upload-time = "2026-09-27T01:40:38.653Z" }, + { url = "https://files.pythonhosted.org/packages/fd/60/345b8c213389809435d1950136b09991a1af2a66b988d0cd930ecd1b9f19/wrapt-2.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42239c89430eee2d8a6dec39e34677abdbb67fff63caf2467dd6124ea4d4d58", size = 228533, upload-time = "2026-09-27T01:40:40.12Z" }, + { url = "https://files.pythonhosted.org/packages/ea/15/c79f0f5827a9062c6be4fc25dc73e92fe1c014c7bfde2e61c8c0b56a91af/wrapt-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bad63bb4dea3c58e8078a3a173259ac2df5442a437e49c632b4099c0250e803b", size = 238830, upload-time = "2026-09-27T01:40:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/5f/de/79a95ac238c9cae7ae7eb3a18501afc646e3ed61d8d108c725b17bbee301/wrapt-2.5.0-cp314-cp314-win32.whl", hash = "sha256:b58138d19f34e32833e62de5e910bc2a8baae43310b921d783bd39b15227c2dd", size = 100664, upload-time = "2026-09-27T01:40:42.884Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/32de0f1e6a46a82c773430672562d53203406df14a6d73c93abb59b679e9/wrapt-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3c4035d2026b87ef23dd8d165f1f8d3853ee2bd02791cfd22bd8c6226c41ce", size = 105986, upload-time = "2026-09-27T01:40:44.652Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2a/10a7ff69097385de15b3db7d91587a54c26f8025fcbf36a1d9e83a1e0ad1/wrapt-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:def66258d97ebf1e4e97def12c5daa542d1cc728a3da83ed3a43933f56df6dab", size = 103718, upload-time = "2026-09-27T01:40:45.956Z" }, + { url = "https://files.pythonhosted.org/packages/f7/01/963f893b1906ac6c2aecb777c36e9ab2156a4cf89fabf6125e953ec4ad52/wrapt-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:23a9d6cb6413359b76f030d6bbb75340b7669c69da245dde2919a4c93708993b", size = 109290, upload-time = "2026-09-27T01:40:47.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6c/30e04d2b1284de2eea5411850008e0411d1876bf4552dc0990c904a0a783/wrapt-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:21cfe343ef9c2deb865ad0d5c57822266447c88dcf6d8805dd8c363fe367f30c", size = 109597, upload-time = "2026-09-27T01:40:49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/3980fe5a899b69454f66db2991c144ecc828dbbd355ce6cd7b881056ebbc/wrapt-2.5.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dfc38cb672af51fc29696ba9c6f05d2315f5e62c4af2564e50f07f81198a163", size = 283840, upload-time = "2026-09-27T01:40:50.406Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b4/b37001235fd5871b3f31941229f8fef608279353b772dab3ccb248fd8726/wrapt-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181a45000506a6382eb337354ca7e8525690702f1ccf2eae4f23a210ff339543", size = 293622, upload-time = "2026-09-27T01:40:51.868Z" }, + { url = "https://files.pythonhosted.org/packages/09/b3/9b751c6268fa2111efc7e43895105bc0f60a83896b08581009e77563f8c7/wrapt-2.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58b2a87c65cbfb20917ec48ace47f71b1962c1f81dbf18a4052e3037abf72028", size = 262840, upload-time = "2026-09-27T01:40:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/b7b51d601981ccc1f7b9e6023991548dec43dc9d40317597fbe0085bc876/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ea62bc142f4fa8b2e0ab058f50699ccd679ef6199c8fa3cc1c2396c7a659000", size = 288781, upload-time = "2026-09-27T01:40:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/1a84f288246905d0a71d44aa1f470ff8c75df2b96ef791d2938124449cb0/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:425349a99b8c9540399d36620c376dc26e6aca93071cd6fafa239c2f1b5d53a4", size = 260116, upload-time = "2026-09-27T01:40:56.667Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/0572224d1c4a3f0846f82614702ec3110d45c843dcc7781c5f33779e1fdc/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fe09aac4837ec720af606a493e814dcc3f65631984e1b7231b589efcf9917024", size = 278485, upload-time = "2026-09-27T01:40:58.443Z" }, + { url = "https://files.pythonhosted.org/packages/76/44/5a5c111f8ac6dd15f54437c2161588431d3924718a7e4de59c471cd794e9/wrapt-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:bc5607c1911c92530cb402ea90d818933cffb28bd8de9b453a2542279816d8c7", size = 103157, upload-time = "2026-09-27T01:40:59.995Z" }, + { url = "https://files.pythonhosted.org/packages/e6/80/96cc2da58cbc0893f5165f6a0f4f9cb75d7574f409022ad792aa80a0ff3f/wrapt-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7138b0e7990e5555a905c519e8dad17c1da1f20e08b283e202c414229065740f", size = 109683, upload-time = "2026-09-27T01:41:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/c7/70/10dab499970e66c926ba6d404ff456318b68092b8ad0c4608a52160e43a2/wrapt-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5bb346a34499e091e4fa23df58251ad192173c088e5413d893ca1c730c133c7", size = 105953, upload-time = "2026-09-27T01:41:03.041Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/5154954f69afdbf5bdeddc07ed60f30bf6e83ed1e9fb6f96c66cc20e4223/wrapt-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a45a5249a6965d91aac9f991fda7c17e6b8b41fe91592a6099f182bf53c82724", size = 106186, upload-time = "2026-09-27T01:41:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/7db9952d26b1a89afcf22da8ec7948e6ca55c48721488d53f84754eed89d/wrapt-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cbd45dfba6b5c1bfbabe1feb3c0f117fbd62416e98268d9a7cd9ad8802875356", size = 106155, upload-time = "2026-09-27T01:41:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/41a0d7f9cf1f8e6aaecbf4b5b4eaacf4036fa3396c7d814364e07728a041/wrapt-2.5.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc6491d3008ecabf685b0746f03ad8241a0950336939b14addb03af39b51a316", size = 251754, upload-time = "2026-09-27T01:41:07.26Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/dd2de1260a490cd55d083b3c1bc47a36aff0e8363249d108d3b34c091c0e/wrapt-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47abb2bb7f15b416e72fbe5e68e49a6f09331dae6af1ca6055f5aa2251d2bd2f", size = 250768, upload-time = "2026-09-27T01:41:08.681Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/d08297feb5728cd6d3c1133633cad0249c2b7a82eb0213062ebab9cc1266/wrapt-2.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e25e9697f60af41fb86b08697e470b1e7eb6cd6ac0eb25e4b1f519839adc271", size = 232618, upload-time = "2026-09-27T01:41:10.293Z" }, + { url = "https://files.pythonhosted.org/packages/54/52/d8ca61b26c2a34927cc999fc250f1018f415741691581280e6a76cced736/wrapt-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bcd42e7b69c8c1e33a29b79b28de03bdc08876a49745830f9162a3af860e06d0", size = 248043, upload-time = "2026-09-27T01:41:12.132Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5e/ba02904736e2d3b05afd7447b4ddff677ce61762265939555541adb8c668/wrapt-2.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:36703cafc2ec059e118c2175e6cb7ad7299c2924aecdcb1b7a7ebbf7a3e20c19", size = 229577, upload-time = "2026-09-27T01:41:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/a0/94/23968c18a6e37a8a130706dc52ccf4344a71f1fe53c99965eb0a7715a459/wrapt-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc0d09906057ada57a32158a657364ca40b8f86e604da3e7d979069b601502", size = 239372, upload-time = "2026-09-27T01:41:15.459Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ee/8437e73fffa57c96a5f0f6721f942ccf7b1461b64cd965f83e2181e25252/wrapt-2.5.0-cp315-cp315-win32.whl", hash = "sha256:76fb341d5a707a4f211631b8c77259b2df149147e9d9c245ae6ba3dd936bfdfb", size = 100673, upload-time = "2026-09-27T01:41:16.925Z" }, + { url = "https://files.pythonhosted.org/packages/37/6d/6d640f98197d68e61fbeaded20478e4aa840f9c88d11e7a49c8d6d14ae15/wrapt-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:6269637d9a54990430b4a769df15833935a46c4d004d9fe8a153bbadf0b9a097", size = 105999, upload-time = "2026-09-27T01:41:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3e/8b8a0c94f2698c99afb499510824b107c81e9c4a34b3db7e877233a634b7/wrapt-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:1122f4f9e363da804ccba05a9f0f39baa3716eb82452c929258bc3f1420c899b", size = 103728, upload-time = "2026-09-27T01:41:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/37/96/88f08f58759ee3739544cc51941853e946df1f400ba60c6efbeccfb589d7/wrapt-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e3c6fb1c1a516881353186bed9cfcb8899f968c03b3509720c79db0d967acf3b", size = 109275, upload-time = "2026-09-27T01:41:21.213Z" }, + { url = "https://files.pythonhosted.org/packages/29/cc/68846aa92814d0704d4b128a30d7707368be6951e8a8f42f1254ce4ab31c/wrapt-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0a7a369e7fca9fc8c2c50df634382009b09af416853da3d4515e4bb048a5b9ee", size = 109610, upload-time = "2026-09-27T01:41:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/bbaa188dace348b6a403bbf3cc483f3f419ac97700274340e17f2dbc700e/wrapt-2.5.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88bb24b9fdccb1d805258d5648533206eb58c58b6554985c47db53a89c11be85", size = 284602, upload-time = "2026-09-27T01:41:24.094Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/8a3309b0cbd3ab809ee6b76812c3be211f5a08732f321f131d26bb4f078a/wrapt-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e3d110d7f99644946f249927c346d9dba507a78815d1bcebdbf0d94c14c5649", size = 294893, upload-time = "2026-09-27T01:41:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/15/b7/eda8bbdb6a3b7343d2c71e23fb0ebfc15c12fd470e3cce7ea42f7a57aaac/wrapt-2.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ffb2823c95dbeb8a47fedfba9636b2afeb0a8ef94b66df97bd081bdfe5a263f", size = 264658, upload-time = "2026-09-27T01:41:27.375Z" }, + { url = "https://files.pythonhosted.org/packages/41/f0/589bad71ca3ce5444a626ee10d657fd6aa5080ada76b3bcd4550468aa16e/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:557ebf4ce5568588368675014a2540405db687c2e4c7ad1eb83aa7e857be1864", size = 289974, upload-time = "2026-09-27T01:41:29.032Z" }, + { url = "https://files.pythonhosted.org/packages/dc/97/c48f3c820ae6687e87b537041cd49ad4caa41e05a8f0d8ec08e449ca303c/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:05246a100da68259af521b88788f131ba005465f1c95d358cc3c03ec5e351b52", size = 261945, upload-time = "2026-09-27T01:41:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ad/d96898f500cb1e4185474bac6cb14bb7ea670a32f37e8c354a0c647e3a92/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c932273bc43b068538f3874fa5e6c2a60f33fa0b11c1ebc7768652f6a0608943", size = 279744, upload-time = "2026-09-27T01:41:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/7981d2ac838d0dc07e81145cb1c9911812e060b98e8c5a47c84fb92f8f81/wrapt-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:2fd8a61c31220840c7f52621cf51c961af5058bd009a55bcdf4a6732bdb13b35", size = 103124, upload-time = "2026-09-27T01:41:34.117Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1d/374cec175b6087e1067a780374d81d74ca966e1e5e39e666402eaee19a65/wrapt-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1d4da5f0e9a719471502b0db80d5c97503aeca796b7aeb9ab8f47403b2be76e6", size = 109671, upload-time = "2026-09-27T01:41:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/fc9e477a1771bec52d7677eee5e8404afe662a47efe1859405a18fff206c/wrapt-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78b7bdaa8b27b7f7607c66bdb6ab15c1dcbd9e9a1556a253a347dad511f615d1", size = 105969, upload-time = "2026-09-27T01:41:36.973Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/5ed859fad4b5eddd598a846150aaab2703730ed4886c5c5e03b0df0cfdd5/wrapt-2.5.0-py3-none-any.whl", hash = "sha256:107eea1a511e98a3a5033b0c2cb403fbb37f05dee6ac1fb85c0460d311ec278c", size = 81586, upload-time = "2026-09-27T01:41:55.479Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/23/655a1802fe8041302c959774ca7c80b53bc24737ff3ef45cb50ef11bd96c/zipp-4.1.1.tar.gz", hash = "sha256:7ebb7a44c021b29fd8dbd7cce6812d0d7b5b454521f93cc71af6ccd155aaa70b", size = 27649, upload-time = "2026-10-03T17:03:03.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/98/df615823cd9419131ce19fba00de53a663794369e198aade064a244b385d/zipp-4.1.1-py3-none-any.whl", hash = "sha256:8979f52d874162f485ff2981e3891f3a3317b7a3dd43ff1e1775b9304f307a9c", size = 10582, upload-time = "2026-10-03T17:03:02.506Z" }, +]