python-build: show download progress in interactive terminals

---
Co-authored-by: Ivan Pozdeev <vano@mail.mipt.ru>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Nemanja Mikic 2026-09-24 00:13:48 +03:00 committed by Ivan Pozdeev
parent 67dbc6e930
commit 8339b9b033
4 changed files with 170 additions and 5 deletions

View File

@ -425,6 +425,18 @@ http_head_aria2c() {
aria2c --dry-run --no-conf=true ${ARIA2_OPTS} "$1" >&4 2>&1
}
# Shows interactive output of a program
# while also logging it.
# Usage:
# command <switches forcing interactive output to non-terminal> 2>&1 | interactive_output; rc=${PIPESTATUS[0]}
interactive_output() {
if [ -n "$VERBOSE" ]; then
cat >> "$LOG_PATH"
else
tee -a "$LOG_PATH" >&3
fi
}
http_get_aria2c() {
# aria2c always treats -o argument as a relative path
local out dir_out;
@ -447,7 +459,26 @@ http_get_aria2c() {
dir_out="$HOME"
fi
if aria2c --allow-overwrite=true --no-conf=true -d "${dir_out}" -o "${out}" ${ARIA2_OPTS} "$1" >&4; then
local aria2c_progress=
# aria2c prints status to stdout rather than stderr.
# As a result, when stdout is redirected, it becomes line-buffered,
# and status messages (which are divided by CRs) are not seen
# until a summary message with some LFs is printed.
# So we have to print summaries often as a crutch.
# But need to make the interval reasonable because each summary is
# several lines long which, made too often, becomes obnoxious.
[ -t 3 ] && aria2c_progress="--summary-interval=5"
local cmd
cmd=(aria2c --allow-overwrite=true --no-conf=true -d "${dir_out}" -o "${out}" ${aria2c_progress} ${ARIA2_OPTS} "$1")
if [ -t 3 ]; then
"${cmd[@]}" 2>&1 | interactive_output
ret=${PIPESTATUS[0]}
else
"${cmd[@]}" >&4
ret=$?
fi
if [ "$ret" -eq 0 ]; then
[ -n "$2" ] || cat "${dir_out:-.}/${out}"
else
false
@ -471,7 +502,21 @@ http_get_curl() {
local curl_is_snap;
if [[ $(command -v curl) == "/snap/"* ]]; then curl_is_snap=1; fi
if [[ -n $2 && -n $curl_is_snap ]]; then out="$HOME/$(basename "$2")"; else out="$2"; fi
curl -q -o "${out:--}" -sSLf ${CURL_OPTS} "$1" || return $?
local curl_progress=
if [ -t 3 ]; then
curl_progress="--no-silent"
fi
local cmd
cmd=(curl -q -o "${out:--}" -sSLf ${CURL_OPTS} ${curl_progress} "$1")
if [ -t 3 ]; then
exec 5>&1
"${cmd[@]}" 3>&1 1>&5 2>&3 3>&- | interactive_output
ret=${PIPESTATUS[0]}
exec 5>&-
[ "$ret" -eq 0 ] || return "$ret"
else
"${cmd[@]}" || return $?
fi
if [[ -n $out && -n $curl_is_snap ]]; then mv "$out" "$2"; fi
}
@ -480,7 +525,21 @@ http_head_wget() {
}
http_get_wget() {
wget -nv ${WGET_OPTS} -O "${2:--}" "$1"
local wget_progress="-nv"
if [ -t 3 ]; then
wget_progress="-nv --show-progress --progress=bar:force:noscroll"
fi
local cmd
cmd=(wget ${WGET_OPTS} ${wget_progress} -O "${2:--}" "$1")
if [ -t 3 ]; then
exec 5>&1
"${cmd[@]}" 3>&1 1>&5 2>&3 3>&- | interactive_output
ret=${PIPESTATUS[0]}
exec 5>&-
return "$ret"
else
"${cmd[@]}"
fi
}
fetch_tarball() {
@ -2777,7 +2836,8 @@ else
BUILD_PATH="$PYTHON_BUILD_BUILD_PATH"
fi
exec 4<> "$LOG_PATH" # open the log file at fd 4
# Append mode needed to be able to `tee` to it at the same time
exec 4>> "$LOG_PATH"
if [ -n "$VERBOSE" ]; then
tail -f "$LOG_PATH" &
TAIL_PID=$!

View File

@ -8,6 +8,21 @@ _setup() {
mkdir -p "${PYTHON_BUILD_BUILD_PATH}"
}
check_script_available() {
command -v script >/dev/null || skip "'script' not installed"
}
run_with_script() {
local command
# `script' outputs CRLF because ttys do so under the hood
# https://unix.stackexchange.com/questions/343324/why-in-the-output-of-script-1-the-newline-is-cr-lf-dos-style
case "$(uname -s)" in
Linux) printf -v command '%q ' "$@"; script -qec "$command" /dev/stdout | tr -d $'\r' ;;
*) script -q /dev/stdout "$@" | tr -d $'\r' ;;
esac </dev/null
return ${PIPESTATUS[0]}
}
@test "failed download displays error message" {
stub curl false
@ -17,6 +32,22 @@ _setup() {
assert_output_contains "error: failed to download package-1.0.0.tar.gz"
}
@test "interactive download progress is both shown and logged" {
check_script_available
export TMPDIR="$BATS_TEST_TMPDIR"
stub curl '** -o * ** --no-silent ** http://example.com/* : "'"$BASH"'" -ec '\''echo download-progress >&2; "$FIXTURE_ROOT/fake_downloader" "$@"'\'' "$@"'
run run_with_script python-build "$FIXTURE_ROOT/definitions/without-checksum" "$INSTALL_ROOT"
assert_success
unstub curl
assert_line download-progress
run cat "$BATS_TEST_TMPDIR"/python-build.*.log
assert_success
assert_line download-progress
}
@test "using aria2c if available" {
export PYTHON_BUILD_ARIA2_OPTS=
export -n PYTHON_BUILD_HTTP_CLIENT
@ -33,6 +64,23 @@ OUT
unstub aria2c
}
@test "interactive aria2c output is emulated by printing summaries" {
check_script_available
export TMPDIR="$BATS_TEST_TMPDIR"
export PYTHON_BUILD_ARIA2_OPTS=
export -n PYTHON_BUILD_HTTP_CLIENT
stub aria2c '** --summary-interval=* ** http://example.com/* : "'"$BASH"'" -ec '\''echo download-progress; "$FIXTURE_ROOT/fake_downloader" aria2c "$@"'\'' "$@"'
run run_with_script python-build "$FIXTURE_ROOT/definitions/without-checksum" "$INSTALL_ROOT"
assert_success
unstub aria2c
assert_line download-progress
run cat "$BATS_TEST_TMPDIR"/python-build.*.log
assert_success
assert_line download-progress
}
@test "fetching from git repository" {
stub git "clone --depth 1 --branch master http://example.com/packages/package.git package-dev : mkdir package-dev"

View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
aria2c=
wget=
src=
dest=
dir_=
case "$1" in
aria2c)
aria2c=1;;
wget)
wget=1;;
esac
while (($#)); do
case "$1" in
-o)
if [[ -z $wget ]]; then
dest="$2"
shift
fi
;;
-O)
if [[ -n $wget ]]; then
dest="$2"
shift
fi
;;
-d)
if [[ -n $aria2c ]]; then
dir_="$2/"
shift
fi
;;
-*)
;;
*)
src="$FIXTURE_ROOT/${1##*/}"
;;
esac
shift
done
cp "$src" "${dir_}${dest}"

View File

@ -138,6 +138,19 @@ assert_output_contains() {
}
}
assert_line() {
if [ "$1" -ge 0 ] 2>/dev/null; then
assert_equal "$2" "${lines[$1]}"
else
local line
for line in "${lines[@]}"; do
if [ "$line" = "$1" ]; then return 0; fi
done
flunk "expected line \`$1'" $'\n'\
"output: $output"
fi
}
# Output a modified PATH that ensures that the given executable is not present,
# but in which system utils necessary for pyenv operation are still available.
path_without() {