From 4e05bff4d0e93ebfc06a4d6d96f614c5e260489c Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Tue, 28 Oct 2025 15:10:54 +0100 Subject: [PATCH] add a`down` feature --- CHANGELOG.md | 1 + src/API.jl | 27 ++++++++++++++++----- src/Operations.jl | 26 ++++++++++++-------- src/Pkg.jl | 11 ++++++++- src/REPLMode/command_declarations.jl | 28 ++++++++++++++++++++++ src/Resolve/Resolve.jl | 16 +++++++++---- src/Resolve/graphtype.jl | 36 ++++++++++++++++++++-------- src/Resolve/maxsum.jl | 11 +++++++-- test/new.jl | 16 +++++++++++++ 9 files changed, 138 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d728179d6..783a50a859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Pkg v1.13 Release Notes improving efficiency when performing several environment changes. ([#4262]) - Added `Pkg.autoprecompilation_enabled(state::Bool)` to globally enable or disable automatic precompilation for Pkg operations. ([#4262]) +- Added `Pkg.downgrade`/`pkg> down` command to resolve packages to their lowest compatible versions ([#XXXX]) - Implemented atomic TOML writes to prevent data corruption when Pkg operations are interrupted or multiple processes write simultaneously. All TOML files are now written atomically using temporary files and atomic moves. ([#4293]) - Implemented lazy loading for RegistryInstance to significantly improve startup performance for operations that don't diff --git a/src/API.jl b/src/API.jl index 9acdcf79e3..19ef16cc37 100644 --- a/src/API.jl +++ b/src/API.jl @@ -154,7 +154,7 @@ function check_readonly(ctx::Context) end # Provide some convenience calls -for f in (:develop, :add, :rm, :up, :pin, :free, :test, :build, :status, :why, :precompile) +for f in (:develop, :add, :rm, :up, :down, :pin, :free, :test, :build, :status, :why, :precompile) @eval begin $f(pkg::Union{AbstractString, PackageSpec}; kwargs...) = $f([pkg]; kwargs...) $f(pkgs::Vector{<:AbstractString}; kwargs...) = $f([PackageSpec(pkg) for pkg in pkgs]; kwargs...) @@ -170,8 +170,8 @@ for f in (:develop, :add, :rm, :up, :pin, :free, :test, :build, :status, :why, : pkgs = deepcopy(pkgs) # don't mutate input foreach(handle_package_input!, pkgs) ret = $f(ctx, pkgs; kwargs...) - $(f in (:up, :pin, :free, :build)) && Pkg._auto_precompile(ctx) - $(f in (:up, :pin, :free, :rm)) && Pkg._auto_gc(ctx) + $(f in (:up, :down, :pin, :free, :build)) && Pkg._auto_precompile(ctx) + $(f in (:up, :down, :pin, :free, :rm)) && Pkg._auto_gc(ctx) return ret end $f(ctx::Context; kwargs...) = $f(ctx, PackageSpec[]; kwargs...) @@ -181,7 +181,7 @@ for f in (:develop, :add, :rm, :up, :pin, :free, :test, :build, :status, :why, : url = nothing, rev = nothing, path = nothing, mode = PKGMODE_PROJECT, subdir = nothing, kwargs... ) pkg = PackageSpec(; name, uuid, version, url, rev, path, subdir) - if $f === status || $f === rm || $f === up + if $f === status || $f === rm || $f === up || $f === down kwargs = merge((; kwargs...), (:mode => mode,)) end # Handle $f() case @@ -436,13 +436,16 @@ function up( preserve::Union{Nothing, PreserveLevel} = isempty(pkgs) ? nothing : PRESERVE_ALL, update_registry::Bool = true, skip_writing_project::Bool = false, + downgrade::Bool = false, kwargs... ) Context!(ctx; kwargs...) Operations.ensure_manifest_registries!(ctx) check_readonly(ctx) if Operations.is_fully_pinned(ctx) - printpkgstyle(ctx.io, :Update, "All dependencies are pinned - nothing to update.", color = Base.info_color()) + msg = downgrade ? "All dependencies are pinned - nothing to downgrade." : "All dependencies are pinned - nothing to update." + action = downgrade ? :Downgrade : :Update + printpkgstyle(ctx.io, action, msg, color = Base.info_color()) return end if update_registry @@ -462,10 +465,22 @@ function up( for pkg in pkgs update_source_if_set(ctx.env, pkg) end - Operations.up(ctx, pkgs, level; skip_writing_project, preserve) + Operations.up(ctx, pkgs, level; skip_writing_project, preserve, downgrade) return end +function down( + ctx::Context, pkgs::Vector{PackageSpec}; + level::UpgradeLevel = UPLEVEL_MAJOR, mode::PackageMode = PKGMODE_PROJECT, + preserve::Union{Nothing, PreserveLevel} = isempty(pkgs) ? nothing : PRESERVE_ALL, + update_registry::Bool = true, + skip_writing_project::Bool = false, + kwargs... + ) + # down is just up with downgrade=true + return up(ctx, pkgs; level, mode, preserve, update_registry, skip_writing_project, downgrade = true, kwargs...) +end + resolve(; io::IO = stderr_f(), kwargs...) = resolve(Context(; io); kwargs...) function resolve(ctx::Context; skip_writing_project::Bool = false, kwargs...) up(ctx; level = UPLEVEL_FIXED, mode = PKGMODE_MANIFEST, update_registry = false, skip_writing_project, kwargs...) diff --git a/src/Operations.jl b/src/Operations.jl index 077b6aaacb..1fde4425d0 100644 --- a/src/Operations.jl +++ b/src/Operations.jl @@ -695,7 +695,7 @@ end # all versioned packages should have a `tree_hash` function resolve_versions!( env::EnvCache, registries::Vector{Registry.RegistryInstance}, pkgs::Vector{PackageSpec}, julia_version, - installed_only::Bool + installed_only::Bool, downgrade::Bool = false ) installed_only = installed_only || OFFLINE_MODE[] @@ -763,7 +763,7 @@ function resolve_versions!( # happened on a different julia version / commit and the stdlib version in the manifest is not the current stdlib version unbind_stdlibs = julia_version === VERSION reqs = Resolve.Requires(pkg.uuid => is_stdlib(pkg.uuid) && unbind_stdlibs ? VersionSpec("*") : VersionSpec(pkg.version) for pkg in pkgs) - graph, compat_map = deps_graph(env, registries, names, reqs, fixed, julia_version, installed_only) + graph, compat_map = deps_graph(env, registries, names, reqs, fixed, julia_version, installed_only, downgrade) Resolve.simplify_graph!(graph) vers = Resolve.resolve(graph) @@ -835,7 +835,7 @@ const PKGORIGIN_HAVE_VERSION = :version in fieldnames(Base.PkgOrigin) function deps_graph( env::EnvCache, registries::Vector{Registry.RegistryInstance}, uuid_to_name::Dict{UUID, String}, reqs::Resolve.Requires, fixed::Dict{UUID, Resolve.Fixed}, julia_version, - installed_only::Bool + installed_only::Bool, downgrade::Bool = false ) uuids = Set{UUID}() union!(uuids, keys(reqs)) @@ -981,7 +981,7 @@ function deps_graph( fixed = fixed_filtered end - return Resolve.Graph(all_compat, weak_compat, uuid_to_name, reqs, fixed, false, julia_version), + return Resolve.Graph(all_compat, weak_compat, uuid_to_name, reqs, fixed, false, julia_version, downgrade), all_compat end @@ -2324,18 +2324,17 @@ function load_manifest_deps_up( return pkgs end -function targeted_resolve_up(env::EnvCache, registries::Vector{Registry.RegistryInstance}, pkgs::Vector{PackageSpec}, preserve::PreserveLevel, julia_version) +function targeted_resolve_up(env::EnvCache, registries::Vector{Registry.RegistryInstance}, pkgs::Vector{PackageSpec}, preserve::PreserveLevel, julia_version, downgrade::Bool = false) pkgs = load_manifest_deps_up(env, pkgs; preserve = preserve) check_registered(registries, pkgs) - deps_map = resolve_versions!(env, registries, pkgs, julia_version, preserve == PRESERVE_ALL_INSTALLED) + deps_map = resolve_versions!(env, registries, pkgs, julia_version, preserve == PRESERVE_ALL_INSTALLED, downgrade) return pkgs, deps_map end function up( ctx::Context, pkgs::Vector{PackageSpec}, level::UpgradeLevel; - skip_writing_project::Bool = false, preserve::Union{Nothing, PreserveLevel} = nothing + skip_writing_project::Bool = false, preserve::Union{Nothing, PreserveLevel} = nothing, downgrade::Bool = false ) - requested_pkgs = pkgs new_git = Set{UUID}() @@ -2353,11 +2352,11 @@ function up( up_load_manifest_info!(pkg, entry) end if preserve !== nothing - pkgs, deps_map = targeted_resolve_up(ctx.env, ctx.registries, pkgs, preserve, ctx.julia_version) + pkgs, deps_map = targeted_resolve_up(ctx.env, ctx.registries, pkgs, preserve, ctx.julia_version, downgrade) else pkgs = load_direct_deps(ctx.env, pkgs; preserve = (level == UPLEVEL_FIXED ? PRESERVE_NONE : PRESERVE_DIRECT)) check_registered(ctx.registries, pkgs) - deps_map = resolve_versions!(ctx.env, ctx.registries, pkgs, ctx.julia_version, false) + deps_map = resolve_versions!(ctx.env, ctx.registries, pkgs, ctx.julia_version, false, downgrade) end update_manifest!(ctx.env, pkgs, deps_map, ctx.julia_version, ctx.registries) new_apply = download_source(ctx) @@ -2395,6 +2394,13 @@ function up( return build_versions(ctx, union(new_apply, new_git)) end +function down( + ctx::Context, pkgs::Vector{PackageSpec}, level::UpgradeLevel; + skip_writing_project::Bool = false, preserve::Union{Nothing, PreserveLevel} = nothing + ) + return up(ctx, pkgs, level; skip_writing_project, preserve, downgrade = true) +end + function update_package_pin!(registries::Vector{Registry.RegistryInstance}, pkg::PackageSpec, entry::Union{Nothing, PackageEntry}) if entry === nothing cmd = Pkg.in_repl_mode() ? "pkg> resolve" : "Pkg.resolve()" diff --git a/src/Pkg.jl b/src/Pkg.jl index 1a992788c1..16352db442 100644 --- a/src/Pkg.jl +++ b/src/Pkg.jl @@ -22,7 +22,7 @@ export UpgradeLevel, UPLEVEL_MAJOR, UPLEVEL_MINOR, UPLEVEL_PATCH export PreserveLevel, PRESERVE_TIERED_INSTALLED, PRESERVE_TIERED, PRESERVE_ALL_INSTALLED, PRESERVE_ALL, PRESERVE_DIRECT, PRESERVE_SEMVER, PRESERVE_NONE export Registry, RegistrySpec -public activate, add, build, compat, develop, free, gc, generate, instantiate, +public activate, add, build, compat, develop, downgrade, free, gc, generate, instantiate, pin, precompile, readonly, redo, rm, resolve, status, test, undo, update, why depots() = Base.DEPOT_PATH @@ -353,6 +353,15 @@ See also [`PackageSpec`](@ref), [`PackageMode`](@ref), [`UpgradeLevel`](@ref). """ const update = API.up +""" + Pkg.downgrade(; kwargs...) + Pkg.downgrade(pkg::Union{String, Vector{String}}; kwargs...) + Pkg.downgrade(pkgs::Union{PackageSpec, Vector{PackageSpec}}; kwargs...) + +Same as `Pkg.update` but prefer lower versions over higher. +""" +const downgrade = API.down + """ Pkg.test(; kwargs...) Pkg.test(pkg::Union{String, Vector{String}; kwargs...) diff --git a/src/REPLMode/command_declarations.jl b/src/REPLMode/command_declarations.jl index e88683e169..a87d2f92f1 100644 --- a/src/REPLMode/command_declarations.jl +++ b/src/REPLMode/command_declarations.jl @@ -390,6 +390,34 @@ compound_declarations = [ After any package updates the project will be precompiled. For more information see `pkg> ?precompile`. """, ], + PSA[ + :name => "downgrade", + :short_name => "down", + :api => API.down, + :should_splat => false, + :arg_count => 0 => Inf, + :arg_parser => parse_package, + :option_spec => [ + PSA[:name => "project", :short_name => "p", :api => :mode => PKGMODE_PROJECT], + PSA[:name => "manifest", :short_name => "m", :api => :mode => PKGMODE_MANIFEST], + PSA[:name => "major", :api => :level => UPLEVEL_MAJOR], + PSA[:name => "minor", :api => :level => UPLEVEL_MINOR], + PSA[:name => "patch", :api => :level => UPLEVEL_PATCH], + PSA[:name => "fixed", :api => :level => UPLEVEL_FIXED], + PSA[:name => "preserve", :takes_arg => true, :api => :preserve => do_preserve], + ], + :completions => :complete_installed_packages, + :description => "downgrade packages to minimum compatible versions", + :help => md""" + [down|downgrade] [-p|--project] [opts] pkg[=uuid] [@version] ... + [down|downgrade] [-m|--manifest] [opts] pkg[=uuid] [@version] ... + + opts: --major | --minor | --patch | --fixed + --preserve= + + Same as `update` except prefer lower versions instead of higher. + """, + ], PSA[ :name => "generate", :api => API.generate, diff --git a/src/Resolve/Resolve.jl b/src/Resolve/Resolve.jl index 93d0f036bd..7e675aa84c 100644 --- a/src/Resolve/Resolve.jl +++ b/src/Resolve/Resolve.jl @@ -80,6 +80,7 @@ function _resolve(graph::Graph, lower_bound::Union{Vector{Int}, Nothing}, previo np = graph.np spp = graph.spp gconstr = graph.gconstr + downgrade = graph.downgrade if lower_bound ≢ nothing for p0 in 1:np @@ -134,6 +135,10 @@ function _resolve(graph::Graph, lower_bound::Union{Vector{Int}, Nothing}, previo log_event_global!(graph, "the solver found an optimal configuration") return sol else + if downgrade + log_event_global!(graph, "downgrade mode: using feasible configuration without further optimization") + return sol + end enforce_optimality!(sol, graph) if lower_bound ≢ nothing @assert all(sol .≥ lower_bound) @@ -308,6 +313,7 @@ function greedysolver(graph::Graph) gadj = graph.gadj gmsk = graph.gmsk np = graph.np + downgrade = graph.downgrade push_snapshot!(graph) gconstr = graph.gconstr @@ -320,10 +326,10 @@ function greedysolver(graph::Graph) # since it may include implicit requirements) req_inds = Set{Int}(p0 for p0 in 1:np if !gconstr[p0][end]) - # set up required packages to their highest allowed versions + # set up required packages to their highest (or lowest in downgrade mode) allowed versions for rp0 in req_inds - # look for the highest version which satisfies the requirements - rv0 = findlast(gconstr[rp0]) + # look for the highest/lowest version which satisfies the requirements + rv0 = downgrade ? findfirst(gconstr[rp0]) : findlast(gconstr[rp0]) @assert rv0 ≢ nothing && rv0 ≠ spp[rp0] sol[rp0] = rv0 fill!(gconstr[rp0], false) @@ -353,8 +359,8 @@ function greedysolver(graph::Graph) # scan dependencies for (j1, p1) in enumerate(gadj[p0]) msk = gmsk[p0][j1] - # look for the highest version which satisfies the requirements - v1 = findlast(msk[:, s0] .& gconstr[p1]) + # look for the highest/lowest version which satisfies the requirements + v1 = downgrade ? findfirst(msk[:, s0] .& gconstr[p1]) : findlast(msk[:, s0] .& gconstr[p1]) v1 == spp[p1] && continue # p1 is not required by p0's current version # if we found a version, and the package was uninstalled # or the same version was already selected, we're ok; diff --git a/src/Resolve/graphtype.jl b/src/Resolve/graphtype.jl index 7365bd0b34..e92303e5d0 100644 --- a/src/Resolve/graphtype.jl +++ b/src/Resolve/graphtype.jl @@ -229,6 +229,9 @@ mutable struct Graph # number of packages (all Vectors above have this length) np::Int + # downgrade mode: if true, prefer lower versions instead of higher + downgrade::Bool + # some workspace vectors newmsg::Vector{FieldValue} diff::Vector{FieldValue} @@ -241,7 +244,8 @@ mutable struct Graph reqs::Requires, fixed::Dict{UUID, Fixed}, verbose::Bool = false, - julia_version::Union{VersionNumber, Nothing} = VERSION + julia_version::Union{VersionNumber, Nothing} = VERSION, + downgrade::Bool = false ) # Tell the resolver about julia itself @@ -342,7 +346,7 @@ mutable struct Graph graph = new( data, gadj, gmsk, gconstr, adjdict, req_inds, fix_inds, ignored, solve_stack, spp, np, - FieldValue[], FieldValue[], FieldValue[] + downgrade, FieldValue[], FieldValue[], FieldValue[] ) _add_fixed!(graph, fixed) @@ -366,8 +370,9 @@ mutable struct Graph fix_inds = copy(graph.fix_inds) ignored = copy(graph.ignored) solve_stack = [([copy(gc0) for gc0 in sav_gconstr], copy(sav_ignored)) for (sav_gconstr, sav_ignored) in graph.solve_stack] + downgrade = graph.downgrade - return new(data, gadj, gmsk, gconstr, adjdict, req_inds, fix_inds, ignored, solve_stack, spp, np) + return new(data, gadj, gmsk, gconstr, adjdict, req_inds, fix_inds, ignored, solve_stack, spp, np, downgrade) end end @@ -1206,6 +1211,7 @@ function validate_versions!(graph::Graph, sources::Set{Int} = Set{Int}(); skim:: id(p0::Int) = pkgID(p0, graph) log_event_global!(graph, "validating versions [mode=$(skim ? "skim" : "deep")]") + downgrade = graph.downgrade sumspp = sum(count(gconstr[p0]) for p0 in 1:np) @@ -1220,7 +1226,9 @@ function validate_versions!(graph::Graph, sources::Set{Int} = Set{Int}(); skim:: gconstr0 = gconstr[p0] old_gconstr0 = copy(gconstr0) - for v0 in reverse!(findall(gconstr0)) + version_indices = findall(gconstr0) + downgrade || reverse!(version_indices) # prefer higher versions when upgrading + for v0 in version_indices push_snapshot!(graph) fill!(graph.gconstr[p0], false) graph.gconstr[p0][v0] = true @@ -1244,13 +1252,15 @@ function validate_versions!(graph::Graph, sources::Set{Int} = Set{Int}(); skim:: changed = true unsat = !any(gconstr0) if unsat - # we'll trigger a failure by pinning the highest version - v0 = findlast(old_gconstr0[1:(end - 1)]) + # we'll trigger a failure by pinning the highest (or lowest) version + selector = downgrade ? findfirst : findlast + v0 = selector(old_gconstr0[1:(end - 1)]) @assert v0 ≢ nothing # this should be ensured by a previous pruning # @info "pinning $(logstr(id(p0))) to version $(pvers[p0][v0])" log_event_pin!(graph, pkgs[p0], pvers[p0][v0]) graph.gconstr[p0][v0] = true - err_msg_preamble = "Package $(logstr(id(p0))) has no possible versions; here is the log when trying to validate the highest version left until this point, $(logstr(id(p0), pvers[p0][v0]))):\n" + direction = downgrade ? "lowest" : "highest" + err_msg_preamble = "Package $(logstr(id(p0))) has no possible versions; here is the log when trying to validate the $direction version left until this point, $(logstr(id(p0), pvers[p0][v0]))):\n" propagate_constraints!(graph, Set{Int}([p0]); err_msg_preamble) @assert false # the above call must fail end @@ -1397,6 +1407,7 @@ function build_eq_classes_soft1!(graph::Graph, p0::Int) gmsk = graph.gmsk gconstr = graph.gconstr ignored = graph.ignored + downgrade = graph.downgrade # concatenate all the constraints; the columns of the # result encode the behavior of each version @@ -1417,10 +1428,15 @@ function build_eq_classes_soft1!(graph::Graph, p0::Int) neq == eff_spp0 && return # nothing to do here # group versions into sets that behave identically - # each set is represented by its highest-valued member - repr_vers = sort!(Int[findlast(isequal(repr_vecs[w0]), cvecs) for w0 in 1:neq]) + # each set is represented by its extreme member (highest for upgrade, lowest for downgrade) + selector = downgrade ? findfirst : findlast + repr_vers = sort!(Int[selector(isequal(repr_vecs[w0]), cvecs) for w0 in 1:neq]) @assert all(>(0), repr_vers) - @assert repr_vers[end] == eff_spp0 + if downgrade + @assert repr_vers[1] == 1 + else + @assert repr_vers[end] == eff_spp0 + end # convert the version numbers into the original numbering repr_vers = findall(gconstr0)[repr_vers] diff --git a/src/Resolve/maxsum.jl b/src/Resolve/maxsum.jl index 7fb71f12c7..d67c226264 100644 --- a/src/Resolve/maxsum.jl +++ b/src/Resolve/maxsum.jl @@ -52,11 +52,16 @@ mutable struct Messages ignored = graph.ignored pvers = graph.data.pvers pdict = graph.data.pdict + downgrade = graph.downgrade ## generate wveights (v0 == spp[p0] is the "uninstalled" state) + # In downgrade mode, we negate the version weights to prefer lower versions vweight = [[VersionWeight(v0 < spp[p0] ? pvers[p0][v0] : v"0") for v0 in 1:spp[p0]] for p0 in 1:np] + if downgrade + vweight = [[-w for w in vweight[p0]] for p0 in 1:np] + end - # external fields: favor newest versions over older, and no-version over all; + # external fields: favor newest versions over older (or oldest in downgrade mode), and no-version over all; # explicit requirements use level l1 instead of l2 fv(p0, v0) = p0 ∈ req_inds ? FieldValue(0, vweight[p0][v0], zero(VersionWeight), (v0 == spp[p0])) : @@ -371,6 +376,7 @@ function converge!(graph::Graph, msgs::Messages, strace::SolutionTrace, perm::No yield() isopen(timer) || return :timedout + downgrade = graph.downgrade is_best_sofar = update_solution!(strace, graph) # this is the base of the recursion: the case when @@ -387,7 +393,8 @@ function converge!(graph::Graph, msgs::Messages, strace::SolutionTrace, perm::No if maxdiff isa Unsat if is_best_sofar p0 = maxdiff.p0 - s0 = findlast(graph.gconstr[p0]) + selector = downgrade ? findfirst : findlast + s0 = selector(graph.gconstr[p0]) strace.staged = (p0, s0) end return :unsat diff --git a/test/new.jl b/test/new.jl index c2496fae17..2be4224f15 100644 --- a/test/new.jl +++ b/test/new.jl @@ -2111,6 +2111,22 @@ end end end +@testset "downgrade: prefer lower versions" begin + # Basic testing that downgrade works opposite to update + isolate(loaded_depot = true) do + Pkg.add(name = "Example", version = "0.5.5") + @test Pkg.dependencies()[exuuid].version == v"0.5.5" + # Downgrade should find a lower version + Pkg.downgrade("Example") + downgraded_version = Pkg.dependencies()[exuuid].version + @test downgraded_version < v"0.5.5" + # Update should bring it back up + Pkg.update("Example") + updated_version = Pkg.dependencies()[exuuid].version + @test updated_version > downgraded_version + end +end + @testset "update: package state changes" begin # basic update on old registered package isolate(loaded_depot = true) do