diff --git a/src/API.jl b/src/API.jl index d8853d02dd..0677e163ef 100644 --- a/src/API.jl +++ b/src/API.jl @@ -1268,7 +1268,7 @@ function instantiate(ctx::Context; manifest::Union{Bool, Nothing}=nothing, # Install all packages new_apply = Operations.download_source(ctx) # Install all artifacts - Operations.download_artifacts(ctx.env; platform, verbose, io=ctx.io) + Operations.download_artifacts(ctx; platform, verbose) # Run build scripts allow_build && Operations.build_versions(ctx, union(new_apply, new_git); verbose=verbose) diff --git a/src/Artifacts.jl b/src/Artifacts.jl index ae47811e06..684f1556ef 100644 --- a/src/Artifacts.jl +++ b/src/Artifacts.jl @@ -301,6 +301,7 @@ function download_artifact( verbose::Bool = false, quiet_download::Bool = false, io::IO=stderr_f(), + progress::Union{Function, Nothing} = nothing, ) if artifact_exists(tree_hash) return true @@ -323,8 +324,8 @@ function download_artifact( temp_dir = mktempdir(artifacts_dir) try - download_verify_unpack(tarball_url, tarball_hash, temp_dir, ignore_existence=true, verbose=verbose, - quiet_download=quiet_download, io=io) + download_verify_unpack(tarball_url, tarball_hash, temp_dir; + ignore_existence=true, verbose, quiet_download, io, progress) calc_hash = SHA1(GitTools.tree_hash(temp_dir)) # Did we get what we expected? If not, freak out. @@ -394,82 +395,101 @@ function ensure_artifact_installed(name::String, artifacts_toml::String; pkg_uuid::Union{Base.UUID,Nothing}=nothing, verbose::Bool = false, quiet_download::Bool = false, + progress::Union{Function,Nothing} = nothing, io::IO=stderr_f()) meta = artifact_meta(name, artifacts_toml; pkg_uuid=pkg_uuid, platform=platform) if meta === nothing error("Cannot locate artifact '$(name)' in '$(artifacts_toml)'") end - return ensure_artifact_installed(name, meta, artifacts_toml; platform=platform, - verbose=verbose, quiet_download=quiet_download, io=io) + return ensure_artifact_installed(name, meta, artifacts_toml; + platform, verbose, quiet_download, progress, io) end function ensure_artifact_installed(name::String, meta::Dict, artifacts_toml::String; platform::AbstractPlatform = HostPlatform(), verbose::Bool = false, quiet_download::Bool = false, + progress::Union{Function,Nothing} = nothing, io::IO=stderr_f()) - hash = SHA1(meta["git-tree-sha1"]) + hash = SHA1(meta["git-tree-sha1"]) if !artifact_exists(hash) - errors = Any[] - # first try downloading from Pkg server - # TODO: only do this if Pkg server knows about this package - if (server = pkg_server()) !== nothing - url = "$server/artifact/$hash" - download_success = let url=url - @debug "Downloading artifact from Pkg server" name artifacts_toml platform url - with_show_download_info(io, name, quiet_download) do - download_artifact(hash, url; verbose=verbose, quiet_download=quiet_download, io=io) - end - end - # download_success is either `true` or an error object - if download_success === true - return artifact_path(hash) - else - @debug "Failed to download artifact from Pkg server" download_success - push!(errors, (url, download_success)) - end + if isnothing(progress) || verbose == true + return try_artifact_download_sources(name, hash, meta, artifacts_toml; platform, verbose, quiet_download, io) + else + # if a custom progress handler is given it is taken to mean the caller wants to handle the download scheduling + return () -> try_artifact_download_sources(name, hash, meta, artifacts_toml; platform, quiet_download=true, io, progress) end + else + return artifact_path(hash) + end +end - # If this artifact does not exist on-disk already, ensure it has download - # information, then download it! - if !haskey(meta, "download") - error("Cannot automatically install '$(name)'; no download section in '$(artifacts_toml)'") +function try_artifact_download_sources( + name::String, hash::SHA1, meta::Dict, artifacts_toml::String; + platform::AbstractPlatform=HostPlatform(), + verbose::Bool=false, + quiet_download::Bool=false, + io::IO=stderr_f(), + progress::Union{Function,Nothing}=nothing) + + errors = Any[] + # first try downloading from Pkg server + # TODO: only do this if Pkg server knows about this package + if (server = pkg_server()) !== nothing + url = "$server/artifact/$hash" + download_success = let url = url + @debug "Downloading artifact from Pkg server" name artifacts_toml platform url + with_show_download_info(io, name, quiet_download) do + download_artifact(hash, url; verbose, quiet_download, io, progress) + end end + # download_success is either `true` or an error object + if download_success === true + return artifact_path(hash) + else + @debug "Failed to download artifact from Pkg server" download_success + push!(errors, (url, download_success)) + end + end - # Attempt to download from all sources - for entry in meta["download"] - url = entry["url"] - tarball_hash = entry["sha256"] - download_success = let url=url - @debug "Downloading artifact" name artifacts_toml platform url - with_show_download_info(io, name, quiet_download) do - download_artifact(hash, url, tarball_hash; verbose=verbose, quiet_download=quiet_download, io=io) - end - end - # download_success is either `true` or an error object - if download_success === true - return artifact_path(hash) - else - @debug "Failed to download artifact" download_success - push!(errors, (url, download_success)) + # If this artifact does not exist on-disk already, ensure it has download + # information, then download it! + if !haskey(meta, "download") + error("Cannot automatically install '$(name)'; no download section in '$(artifacts_toml)'") + end + + # Attempt to download from all sources + for entry in meta["download"] + url = entry["url"] + tarball_hash = entry["sha256"] + download_success = let url = url + @debug "Downloading artifact" name artifacts_toml platform url + with_show_download_info(io, name, quiet_download) do + download_artifact(hash, url, tarball_hash; verbose, quiet_download, io, progress) end end - errmsg = """ - Unable to automatically download/install artifact '$(name)' from sources listed in '$(artifacts_toml)'. - Sources attempted: - """ - for (url, err) in errors - errmsg *= "- $(url)\n" - errmsg *= " Error: $(sprint(showerror, err))\n" + # download_success is either `true` or an error object + if download_success === true + return artifact_path(hash) + else + @debug "Failed to download artifact" download_success + push!(errors, (url, download_success)) end - error(errmsg) - else - return artifact_path(hash) end + errmsg = """ + Unable to automatically download/install artifact '$(name)' from sources listed in '$(artifacts_toml)'. + Sources attempted: + """ + for (url, err) in errors + errmsg *= "- $(url)\n" + errmsg *= " Error: $(sprint(showerror, err))\n" + end + error(errmsg) end + function with_show_download_info(f, io, name, quiet_download) fancyprint = can_fancyprint(io) if !quiet_download @@ -485,7 +505,7 @@ function with_show_download_info(f, io, name, quiet_download) if !quiet_download fancyprint && print(io, "\033[1A") # move cursor up one line fancyprint && print(io, "\033[2K") # clear line - if success + if success fancyprint && printpkgstyle(io, :Downloaded, "artifact: $name") else printpkgstyle(io, :Failure, "artifact: $name", color = :red) diff --git a/src/MiniProgressBars.jl b/src/MiniProgressBars.jl index 564a803917..1aae629f79 100644 --- a/src/MiniProgressBars.jl +++ b/src/MiniProgressBars.jl @@ -4,18 +4,31 @@ export MiniProgressBar, start_progress, end_progress, show_progress, print_progr using Printf +# Until Base.format_bytes supports sigdigits +function pkg_format_bytes(bytes; binary=true, sigdigits::Integer=3) + units = binary ? Base._mem_units : Base._cnt_units + factor = binary ? 1024 : 1000 + bytes, mb = Base.prettyprint_getunits(bytes, length(units), Int64(factor)) + if mb == 1 + return string(Int(bytes), " ", Base._mem_units[mb], bytes==1 ? "" : "s") + else + return string(Base.Ryu.writefixed(Float64(bytes), sigdigits), binary ? " $(units[mb])" : "$(units[mb])B") + end +end + Base.@kwdef mutable struct MiniProgressBar max::Int = 1.0 header::String = "" color::Symbol = :nothing width::Int = 40 - current::Int = 0.0 - prev::Int = 0.0 + current::Int = 0 + prev::Int = 0 has_shown::Bool = false time_shown::Float64 = 0.0 - percentage::Bool = true + mode::Symbol = :percentage # :percentage :int :data always_reprint::Bool = false indent::Int = 4 + main::Bool = true end const PROGRESS_BAR_TIME_GRANULARITY = Ref(1 / 30.0) # 30 fps @@ -47,21 +60,32 @@ function show_progress(io::IO, p::MiniProgressBar; termwidth=nothing, carriagere p.prev = p.current p.has_shown = true - progress_text = if p.percentage + progress_text = if p.mode == :percentage @sprintf "%2.1f %%" perc - else + elseif p.mode == :int string(p.current, "/", p.max) + elseif p.mode == :data + lpad(string(pkg_format_bytes(p.current; sigdigits=1), "/", pkg_format_bytes(p.max; sigdigits=1)), 20) + else + error("Unknown mode $(p.mode)") end termwidth = @something termwidth displaysize(io)[2] max_progress_width = max(0, min(termwidth - textwidth(p.header) - textwidth(progress_text) - 10 , p.width)) n_filled = ceil(Int, max_progress_width * perc / 100) n_left = max_progress_width - n_filled + headers = split(p.header) to_print = sprint(; context=io) do io print(io, " "^p.indent) - printstyled(io, p.header, color=p.color, bold=true) - print(io, " [") - print(io, "="^n_filled, ">") - print(io, " "^n_left, "] ", ) + if p.main + printstyled(io, headers[1], " "; color=:green, bold=true) + printstyled(io, join(headers[2:end], ' ')) + else + print(io, p.header) + end + print(io, " ") + printstyled(io, "━"^n_filled; color=p.color) + printstyled(io, perc >= 95 ? "━" : "╸"; color=p.color) + printstyled(io, "━"^n_left, " "; color=:light_black) print(io, progress_text) carriagereturn && print(io, "\r") end @@ -80,10 +104,10 @@ end # prog = MiniProgressBar(...) # prog.end = n # for progress in 1:n -# print_progree_bottom(io) +# print_progress_bottom(io) # println("stuff") # prog.current = progress -# showproress(io, prog) +# showprogress(io, prog) # end # function print_progress_bottom(io::IO) diff --git a/src/Operations.jl b/src/Operations.jl index 3e0429cecb..843fa7bc9c 100644 --- a/src/Operations.jl +++ b/src/Operations.jl @@ -816,11 +816,18 @@ function collect_artifacts(pkg_root::String; platform::AbstractPlatform=HostPlat return artifacts_tomls end -function download_artifacts(env::EnvCache; +mutable struct DownloadState + state::Symbol + const bar::MiniProgressBar +end + +function download_artifacts(ctx::Context; platform::AbstractPlatform=HostPlatform(), julia_version = VERSION, - verbose::Bool=false, - io::IO=stderr_f()) + verbose::Bool=false) + env = ctx.env + io = ctx.io + fancyprint = can_fancyprint(io) pkg_roots = String[] for (uuid, pkg) in env.manifest pkg = manifest_info(env.manifest, uuid) @@ -828,15 +835,138 @@ function download_artifacts(env::EnvCache; pkg_root === nothing || push!(pkg_roots, pkg_root) end push!(pkg_roots, dirname(env.project_file)) + used_artifact_tomls = Set{String}() + download_jobs = Function[] + + print_lock = Base.ReentrantLock() # for non-fancyprint printing + + # name -> (state, bar) where state is :ready, :running, :done, or :failed + download_states = Dict{String, DownloadState}() + + errors = Channel{Any}(Inf) + is_done = false + ansi_moveup(n::Int) = string("\e[", n, "A") + ansi_movecol1 = "\e[1G" + ansi_cleartoend = "\e[0J" + ansi_cleartoendofline = "\e[0K" + ansi_enablecursor = "\e[?25h" + ansi_disablecursor = "\e[?25l" + + longest_name_length = 0 # will be updated later for pkg_root in pkg_roots for (artifacts_toml, artifacts) in collect_artifacts(pkg_root; platform) # For each Artifacts.toml, install each artifact we've collected from it for name in keys(artifacts) - ensure_artifact_installed(name, artifacts[name], artifacts_toml; - verbose, quiet_download=!(usable_io(io)), io=io) + bar = MiniProgressBar(; main=false, indent=2, color = Base.info_color(), mode=:data, always_reprint=true) + progress = (total, current) -> (bar.max = total; bar.current = current) + # returns a string if exists, or function that downloads the artifact if not + ret = ensure_artifact_installed(name, artifacts[name], artifacts_toml; + verbose, quiet_download=!(usable_io(io)), io, progress) + if ret isa Function + download_states[name] = DownloadState(:ready, bar) + push!(download_jobs, + () -> begin + try + download_states[name].state = :running + ret() + if !fancyprint + rname = rpad(name, longest_name_length) + @lock print_lock printpkgstyle(io, :Downloaded, "artifact $rname $(MiniProgressBars.pkg_format_bytes(bar.max; sigdigits=1))") + end + catch + download_states[name].state = :failed + rethrow() + else + download_states[name].state = :done + end + end + ) + end + end + push!(used_artifact_tomls, artifacts_toml) + end + end + + if !isempty(download_jobs) + longest_name_length = maximum(textwidth, keys(download_states)) + for (name, dstate) in download_states + dstate.bar.header = rpad(name, longest_name_length) + end + + if fancyprint + t_print = Threads.@spawn begin + try + print(io, ansi_disablecursor) + first = true + timer = Timer(0, interval=1/10) + # TODO: Implement as a new MiniMultiProgressBar + main_bar = MiniProgressBar(; indent=1, header = "Downloading artifacts", color = :green, mode = :int, always_reprint=true) + main_bar.max = length(download_states) + while !is_done + main_bar.current = count(x -> x[2].state == :done, download_states) + str = sprint(context=io) do iostr + first || print(iostr, ansi_cleartoend) + n_printed = 1 + show_progress(iostr, main_bar; carriagereturn=false) + println(iostr) + for (name, dstate) in sort!(collect(download_states), by=kv->kv[2].bar.max, rev=true) + dstate.state == :running && dstate.bar.max > 1000 && dstate.bar.current > 0 || continue + show_progress(iostr, dstate.bar; carriagereturn=false) + println(iostr) + n_printed += 1 + end + is_done || print(iostr, ansi_moveup(n_printed), ansi_movecol1) + first = false + end + print(io, str) + wait(timer) + end + print(io, ansi_cleartoend) + main_bar.current = count(x -> x[2].state == :done, download_states) + show_progress(io, main_bar; carriagereturn=false) + println(io) + catch e + e isa InterruptException || rethrow() + finally + print(io, ansi_enablecursor) + end + end + Base.errormonitor(t_print) + else + printpkgstyle(io, :Downloading, "$(length(download_jobs)) artifacts") + end + sema = Base.Semaphore(ctx.num_concurrent_downloads) + interrupted = false + @sync for f in download_jobs + interrupted && break + Base.acquire(sema) + Threads.@spawn try + f() + catch e + e isa InterruptException && (interrupted = true) + put!(errors, e) + finally + Base.release(sema) end - write_env_usage(artifacts_toml, "artifact_usage.toml") end + is_done = true + fancyprint && wait(t_print) + close(errors) + + if !isempty(errors) + all_errors = collect(errors) + str = sprint(context=io) do iostr + for e in all_errors + Base.showerror(iostr, e) + length(all_errors) > 1 && println(iostr) + end + end + pkgerror("Failed to download some artifacts:\n\n$(strip(str, '\n'))") + end + end + + for f in used_artifact_tomls + write_env_usage(f, "artifact_usage.toml") end end @@ -948,8 +1078,8 @@ function download_source(ctx::Context; readonly=true) end end - bar = MiniProgressBar(; indent=2, header = "Progress", color = Base.info_color(), - percentage=false, always_reprint=true) + bar = MiniProgressBar(; indent=1, header = "Downloading packages", color = Base.info_color(), + mode=:int, always_reprint=true) bar.max = length(pkgs_to_install) fancyprint = can_fancyprint(ctx.io) try @@ -1179,8 +1309,8 @@ function build_versions(ctx::Context, uuids::Set{UUID}; verbose=false) sort!(builds, by = build -> order[first(build)]) max_name = maximum(build->textwidth(build[2]), builds; init=0) - bar = MiniProgressBar(; indent=2, header = "Progress", color = Base.info_color(), - percentage=false, always_reprint=true) + bar = MiniProgressBar(; indent=2, header = "Building packages", color = Base.info_color(), + mode=:int, always_reprint=true) bar.max = length(builds) fancyprint = can_fancyprint(ctx.io) fancyprint && start_progress(ctx.io, bar) @@ -1509,7 +1639,7 @@ function add(ctx::Context, pkgs::Vector{PackageSpec}, new_git=Set{UUID}(); # After downloading resolutionary packages, search for (Julia)Artifacts.toml files # and ensure they are all downloaded and unpacked as well: - download_artifacts(ctx.env, platform=platform, julia_version=ctx.julia_version, io=ctx.io) + download_artifacts(ctx, platform=platform, julia_version=ctx.julia_version) # if env is a package add compat entries if ctx.env.project.name !== nothing && ctx.env.project.uuid !== nothing @@ -1553,7 +1683,7 @@ function develop(ctx::Context, pkgs::Vector{PackageSpec}, new_git::Set{UUID}; update_manifest!(ctx.env, pkgs, deps_map, ctx.julia_version) new_apply = download_source(ctx) fixups_from_projectfile!(ctx.env) - download_artifacts(ctx.env; platform=platform, julia_version=ctx.julia_version, io=ctx.io) + download_artifacts(ctx; platform=platform, julia_version=ctx.julia_version) write_env(ctx.env) # write env before building show_update(ctx.env, ctx.registries; io=ctx.io) build_versions(ctx, union(new_apply, new_git)) @@ -1694,7 +1824,7 @@ function up(ctx::Context, pkgs::Vector{PackageSpec}, level::UpgradeLevel; update_manifest!(ctx.env, pkgs, deps_map, ctx.julia_version) new_apply = download_source(ctx) fixups_from_projectfile!(ctx.env) - download_artifacts(ctx.env, julia_version=ctx.julia_version, io=ctx.io) + download_artifacts(ctx, julia_version=ctx.julia_version) write_env(ctx.env; skip_writing_project) # write env before building show_update(ctx.env, ctx.registries; io=ctx.io, hidden_upgrades_info = true) build_versions(ctx, union(new_apply, new_git)) @@ -1740,7 +1870,7 @@ function pin(ctx::Context, pkgs::Vector{PackageSpec}) update_manifest!(ctx.env, pkgs, deps_map, ctx.julia_version) new = download_source(ctx) fixups_from_projectfile!(ctx.env) - download_artifacts(ctx.env; julia_version=ctx.julia_version, io=ctx.io) + download_artifacts(ctx; julia_version=ctx.julia_version) write_env(ctx.env) # write env before building show_update(ctx.env, ctx.registries; io=ctx.io) build_versions(ctx, new) @@ -1788,7 +1918,7 @@ function free(ctx::Context, pkgs::Vector{PackageSpec}; err_if_free=true) update_manifest!(ctx.env, pkgs, deps_map, ctx.julia_version) new = download_source(ctx) fixups_from_projectfile!(ctx.env) - download_artifacts(ctx.env, io=ctx.io) + download_artifacts(ctx) write_env(ctx.env) # write env before building show_update(ctx.env, ctx.registries; io=ctx.io) build_versions(ctx, new) diff --git a/src/PlatformEngines.jl b/src/PlatformEngines.jl index f3f405ced9..e15b6483bf 100644 --- a/src/PlatformEngines.jl +++ b/src/PlatformEngines.jl @@ -255,7 +255,8 @@ function download( verbose::Bool = false, headers::Vector{Pair{String,String}} = Pair{String,String}[], auth_header::Union{Pair{String,String}, Nothing} = nothing, - io::IO=stderr_f() + io::IO=stderr_f(), + progress::Union{Nothing,Function} = nothing, # (total, now) -> nothing ) if auth_header === nothing auth_header = get_auth_header(url, verbose=verbose) @@ -268,7 +269,9 @@ function download( end do_fancy = verbose && can_fancyprint(io) - progress = if do_fancy + progress = if !isnothing(progress) + progress + elseif do_fancy bar = MiniProgressBar(header="Downloading", color=Base.info_color()) start_progress(io, bar) let bar=bar @@ -326,6 +329,7 @@ function download_verify( verbose::Bool = false, force::Bool = false, quiet_download::Bool = false, + progress::Union{Nothing,Function} = nothing, # (total, now) -> nothing ) # Whether the file existed in the first place file_existed = false @@ -352,7 +356,7 @@ function download_verify( attempts = 3 for i in 1:attempts try - download(url, dest; verbose=verbose || !quiet_download) + download(url, dest; verbose=verbose || !quiet_download, progress) break catch err @debug "download and verify failed on attempt $i/$attempts" url dest err @@ -364,7 +368,8 @@ function download_verify( end end end - if hash !== nothing && !verify(dest, hash; verbose=verbose) + details = String[] + if hash !== nothing && !verify(dest, hash; verbose, details) # If the file already existed, it's possible the initially downloaded chunk # was bad. If verification fails after downloading, auto-delete the file # and start over from scratch. @@ -376,13 +381,15 @@ function download_verify( # Download and verify from scratch download(url, dest; verbose=verbose || !quiet_download) - if hash !== nothing && !verify(dest, hash; verbose=verbose) - error("Verification failed. Download does not match expected hash") + if hash !== nothing && !verify(dest, hash; verbose, details) + @goto verification_failed end else + @label verification_failed # If it didn't verify properly and we didn't resume, something is # very wrong and we must complain mightily. - error("Verification failed. Download does not match expected hash") + details_indented = join(map(s -> " $s", split(join(details, "\n"), '\n')), "\n") + error("Verification failed:\n" * details_indented) end end @@ -466,6 +473,7 @@ function download_verify_unpack( verbose::Bool = false, quiet_download::Bool = false, io::IO=stderr_f(), + progress::Union{Nothing,Function} = nothing, # (total, now) -> nothing ) # First, determine whether we should keep this tarball around remove_tarball = false @@ -510,8 +518,7 @@ function download_verify_unpack( # Download the tarball; if it already existed and we needed to remove it # then we should remove the unpacked path as well - should_delete = !download_verify(url, hash, tarball_path; - force=force, verbose=verbose, quiet_download=quiet_download) + should_delete = !download_verify(url, hash, tarball_path; force, verbose, quiet_download, progress) if should_delete if verbose @info("Removing dest directory $(dest) as source tarball changed") @@ -551,7 +558,8 @@ end """ verify(path::AbstractString, hash::AbstractString; - verbose::Bool = false, report_cache_status::Bool = false) + verbose::Bool = false, report_cache_status::Bool = false, + details::Union{Vector{String},Nothing} = nothing) Given a file `path` and a `hash`, calculate the SHA256 of the file and compare it to `hash`. This method caches verification results in a `"\$(path).sha256"` @@ -567,9 +575,12 @@ If `report_cache_status` is set to `true`, then the return value will be a `Symbol` giving a granular status report on the state of the hash cache, in addition to the `true`/`false` signifying whether verification completed successfully. + +If `details` is provided, any pertinent detail will be pushed to it rather than logged. """ function verify(path::AbstractString, hash::AbstractString; verbose::Bool = false, - report_cache_status::Bool = false, hash_path::AbstractString="$(path).sha256") + report_cache_status::Bool = false, hash_path::AbstractString="$(path).sha256", + details::Union{Vector{String},Nothing} = nothing) # Check hash string format if !occursin(r"^[0-9a-f]{64}$"i, hash) @@ -639,7 +650,11 @@ function verify(path::AbstractString, hash::AbstractString; verbose::Bool = fals msg = "Hash Mismatch!\n" msg *= " Expected sha256: $hash\n" msg *= " Calculated sha256: $calc_hash" - @error(msg) + if isnothing(details) + @error(msg) + else + push!(details, msg) + end if report_cache_status return false, :hash_mismatch else diff --git a/test/artifacts.jl b/test/artifacts.jl index d176dce5a1..202677c6ff 100644 --- a/test/artifacts.jl +++ b/test/artifacts.jl @@ -308,9 +308,7 @@ end mktempdir() do dir with_artifacts_directory(dir) do @test artifact_meta("broken_artifact", joinpath(badifact_dir, "incorrect_sha256.toml")) != nothing - @test_logs (:error, r"Hash Mismatch!") match_mode=:any begin - @test_throws ErrorException ensure_artifact_installed("broken_artifact", joinpath(badifact_dir, "incorrect_sha256.toml")) - end + @test_throws r"Hash Mismatch!" ensure_artifact_installed("broken_artifact", joinpath(badifact_dir, "incorrect_sha256.toml")) artifact_toml = joinpath(badifact_dir, "doesnotexist.toml") @test_throws ErrorException ensure_artifact_installed("does_not_exist", artifact_toml)