#!/usr/bin/env bash :<<'__comments__' Name: install-python Usage: See show_usage() Synopsis: If host distro is $DISTRO_TYPE and version is one of ${DISTRO_VERSIONS[@]} and python version $1 is not already installed, then: 1) retrieve the source archive for that python version from $SRC_URL; 2) unpack the archive to $SRC_PATH; 3) install all RPM package dependencies; 4) configure, make and install python $versionChoice rooted at $BASE_PATH. Python versions above 3.8.17 will likely fail to build on centos|rhel 7. Notes: Credit to https://gist.github.com/ygorth/974a01f33dbd9bcf63b2 and https://linuxstans.com/how-to-install-python-centos/ Author: G. D. LaBossiere, Xview Solutions Inc. Release: 4 Date: 2024-04-23 RCS: $Id$ License: GNU GPL v3 __comments__ #------------------------------------------------------------------------------ # bash environment #------------------------------------------------------------------------------ # BASH_VERSINFO[@] # EXEC_NAME # EXEC_PID # FUNCNAME[@] #------------------------------------------------------------------------------ # constants #------------------------------------------------------------------------------ # local installations are rooted here BASE_PATH="/usr/local" # path to local installation binaries BIN_PATH="${BASE_PATH}/bin" # this script works with alma|centos|redhat|rocky distros DISTRO_TYPE="rhel" # this script works with versions 7 and 8 of alma|centos|redhat|rocky DISTRO_VERSIONS=('7' '8') # path to local installation libraries LIB_PATH="${BASE_PATH}/lib" # building python from source needs these RPM groups; quote each array element RPM_GRP_DEPS=('Development Tools') # building python from source needs these RPM packages RPM_PKG_DEPS=(bzip2-devel db4-devel gdbm-devel libffi-devel libjpeg-devel libpcap-devel libxml2-devel libxslt-devel mysql-devel ncurses-devel openssl-devel readline-devel sqlite sqlite-devel tk-devel unixODBC-devel xz-devel zlib-devel) # path to local installation sources SRC_PATH="${BASE_PATH}/src" # base url for python sources; for each version the full url is composed in #+ get_version_choice() as: $SRC_URL/$versionChoice/Python-$versionChoice.tgz SRC_URL="https://www.python.org/ftp/python" # this script requires these utils to work TOOL_LIST=(chown curl id ls realpath sort sudo tar uniq) #------------------------------------------------------------------------------ # error codes #------------------------------------------------------------------------------ ERROR_10="Requires bash 4.2+" ERROR_15="Missing required program(s)" ERROR_16="Missing required function(s)" ERROR_18="Bad or missing required parameter(s)" ERROR_20="Insufficient user privilege" ERROR_22="Required file(s) not found" ERROR_61="Unsupported operating system or distribution" ERROR_62="Unsupported version" ERROR_250="Invalid access method" ERROR_255="Called program error or user interrupt" #------------------------------------------------------------------------------ # variables #------------------------------------------------------------------------------ argList=() errFuncName="" exitMessage=() pkgMgr="" versionChoice="" #------------------------------------------------------------------------------ :<<'__function_calls__' main─┐ ├─exit_handler ├─check_distrover ├─check_tools ├─check_user_privilege ├─get_user_input ├─check_versions_installed ├─get_version_choice ├─install_required_rpms ├─build_version_choice └─show_usage __function_calls__ #------------------------------------------------------------------------------ # functions #------------------------------------------------------------------------------ :<<'__comments__' Name: build_version_choice Synopsis: Unpack source archive; build and install python versionChoice. Notes: If compiling with gcc < 8.1.0 make will fail with error "Could not import runpy module"; workaround implemented in this function is to remove the config option "--enable-optimizations" Credit to http://tech.yipp.ca/python/import-runpy-module-traceback-recent-call-last-python-3-9-5libtyping-py-line-21-import-collections-systemerror-returned-null-without-setting-error-generate-posix-vars-f/ Globals: BASE_PATH, FUNCNAME[@], LIB_PATH, SRC_PATH, errFuncName, exitMessage[@], versionChoice Requires: chown, id, tar Returns: 0 on success; errFuncName, exitMessage[@] and exit 15, 20 or 255 on error. __comments__ build_version_choice() { [[ $versionChoice ]] || return 0 errFuncName="$FUNCNAME" type -p chown id tar &>/dev/null || exit 15 local admin blddir bldlog cfglog inslog msg blddir="${SRC_PATH}/Python-${versionChoice}" bldlog="${blddir}/build.log" cfglog="${blddir}/config.log" inslog="${blddir}/install.log" # if I'm not root I need to be a nopasswd sudoer [[ $(id -u) -eq 0 ]] || admin="sudo" cd ${SRC_PATH}/ printf '%s' "Attempting to unpack source archive Python-${versionChoice}.tgz" ${admin} tar -zxf Python-${versionChoice}.tgz || { exitMessage+=(" Failed!") exit 255 } printf '%s\n' " Success!" # if I'm a sudoer I need to own the unpacked archive or the build will fail [[ $admin ]] && { printf '%s' "Attempting to change owner of ${blddir} to $(id -un)..." ${admin} chown -R $(id -un) ${blddir} || { exitMessage+=(" Failed!") exit 20 } printf '%s\n' " Success!" } cd ${blddir} msg="configure Python-${versionChoice} for building." printf '%s %s\n' "Attempting to ${msg} This may take a while." \ "Please wait..." # deleted '--enable-optimizations' as workaround to handle runpy error in make ./configure --prefix=${BASE_PATH} LDFLAGS="-Wl,-rpath ${LIB_PATH}" \ --with-ensurepip=install &> ${cfglog} || { exitMessage+=("Failed to ${msg}") exitMessage+=("View ${cfglog} for more information.") exit 255 } printf '%s\n' "Success! View ${cfglog} for more information." msg="build Python-${versionChoice} from source." printf '%s %s\n' "Attempting to ${msg} This may take a (very) long time." \ "Please wait..." make &> ${bldlog} || { exitMessage+=("Failed to ${msg}") exitMessage+=("View ${bldlog} for more information.") exit 255 } printf '%s\n' "Success! View ${bldlog} for more information." msg="install Python-${versionChoice} to ${BASE_PATH}" printf '%s\n' "Attempting to ${msg} Please wait..." ${admin} make altinstall &> ${inslog} || { exitMessage+=("Failed to ${msg}") exitMessage+=("View ${inslog} for more information.") exit 255 } printf '%s %s\n' "Success! Python-${versionChoice} is installed." \ "View ${inslog} for more information." return 0 } #------------------------------------------------------------------------------ :<<'__comments__' Name: check_distrover Synopsis: Get GNU/Linux distro and version installed on this host; verify match with DISTRO_TYPE and one of DISTRO_VERSIONS[@]. Globals: DISTRO_TYPE, DISTRO_VERSIONS[@], FUNCNAME[@], errFuncName, exitMessage[@] Reads: /etc/os-release Returns: 0 on success; errFuncName, exitMessage[@] and exit 22, 61 or 62 on error. __comments__ check_distrover() { errFuncName="$FUNCNAME" [[ -f /etc/os-release ]] || exit 22 local dist i info like msg ver readarray -t info < /etc/os-release for ((i=0;i<${#info[@]};i++)); do [[ ${info[$i]%%=*} =~ ^ID$ ]] && dist="${info[$i]##*=}" [[ ${info[$i]%%=*} =~ ^ID_LIKE$ ]] && like="${info[$i]##*=}" [[ ${info[$i]%%=*} =~ ^VERSION_ID$ ]] && ver="${info[$i]##*=}" [[ $dist && $like && $ver ]] && break 1 done dist="${dist//[\"\']/}" like="${like//[\"\']/}" [[ $dist =~ $DISTRO_TYPE || $like =~ $DISTRO_TYPE ]] || { exitMessage+=("Host distro '${dist}' is not supported.") exit 61 } ver="${ver//[\"\']/}" [[ ${DISTRO_VERSIONS[@]} =~ ${ver%%\.*} ]] || { msg="Host distro ${dist} is supported but version ${ver} is not supported." exitMessage+=("${msg}") exit 62 } return 0 } #------------------------------------------------------------------------------ <<'__comments__' Name: check_tools Synopsis: For each program name in TOOL_LIST[@] not in the USER's PATH or not installed on this host add program name to missing[@]; check for dnf or yum and set global pkgMgr else add 'dnf or yum' to missing[@]; return 0 if missing[@] is empty else set exitMessage[@] and exit 15. Globals: FUNCNAME[@], TOOL_LIST[@], errFuncName, exitMessage[@], pkgMgr Returns: 0 on success; errFuncName, exitMessage[@] and exit 15 on error. __comments__ check_tools() { errFuncName="$FUNCNAME" local i missing msg for ((i=0;i<${#TOOL_LIST[@]};i++)); do type -p ${TOOL_LIST[$i]} &>/dev/null || missing+=("${TOOL_LIST[$i]}") done pkgMgr="$(type -p dnf 2>/dev/null)" [[ $pkgMgr ]] || pkgMgr="$(type -p yum 2>/dev/null)" [[ $pkgMgr ]] || missing+=("dnf or yum") [[ ${missing[@]} ]] || return 0 msg="The following required utilities were not found: ${missing[*]}" exitMessage+=("${msg}") exit 15 } #------------------------------------------------------------------------------ :<<'__comments__' Name: check_user_privilege Synopsis: If EUID is non-zero verify that user is a nopasswd sudoer. Globals: EXEC_NAME, FUNCNAME[@], errFuncName, exitMessage[@] Requires: id, sudo Returns: 0 on success; errFuncName and exit 15 on error; errFuncName, exitMessage[@] and exit 20 if user is not root or a nopasswd sudoer. __comments__ check_user_privilege() { errFuncName="$FUNCNAME" type -p id sudo &>/dev/null || exit 15 [[ $(id -u) -eq 0 ]] && return 0 [[ $(sudo -l -n -U $(id -un)) =~ ALL.*NOPASSWD.*ALL$ ]] && return 0 local msg msg="You must be root or a nopasswd sudoer to run ${EXEC_NAME} ${0##*/}" exitMessage+=("${msg}") exit 20 } #------------------------------------------------------------------------------ :<<'__comments__' Name: check_versions_installed Synopsis: If versionChoice is set search for python binaries installed on this host at /bin, /usr/bin and /usr/local/bin; if none found return 0; if found set exitMessage[@]; if versionChoice matches any version found set and print exitMessage[@] and exit 0; else return 0. Globals: FUNCNAME[@], errFuncName, versionChoice Requires: ls, realpath, sort, uniq Returns: 0 on success; exitMessage[@] and exit 0 if versionChoice is already installed; errFuncName and exit 15 on error. __comments__ check_versions_installed() { [[ $versionChoice ]] || return 0 errFuncName="$FUNCNAME" type -p ls realpath sort uniq &>/dev/null || exit 15 # all done if no version of python is installed; very unlikely type -p python &>/dev/null || return 0 local count i inst list msg ls /usr/bin/python* &>/dev/null && \ readarray -t list <<< "$(realpath /usr/bin/python*)" # if /bin and /usr/bin are not symlinked [[ $(realpath /bin) != $(realpath /usr/bin) ]] && { ls /bin/python* &>/dev/null && \ readarray -O "${#list[@]}" -t list <<< "$(realpath /bin/python*)" } ls /usr/local/bin/python* &>/dev/null && \ readarray -O "${#list[@]}" -t list <<< "$(realpath /usr/local/bin/python*)" [[ ${list[@]} =~ [0-9a-zA-Z] ]] || return 0 # get and list the version numbers of the installed binaries found count="${#list[@]}" for ((i=0;i<${count};i++)); do [[ -x ${list[$i]} && ${list[$i]##*\/} =~ ^python[1-9]\.[1-9][0-9]*$ ]] && { list[$i]="$( ${list[$i]} --version 2>&1 )" list[$i]="${list[$i]##*\ }" } || unset list[$i] done # remove duplicate entries in the list readarray -t inst <<< "$(printf '%s\n' "${list[@]}" | sort | uniq)" msg="$(printf ' %s ' "${inst[@]}")" exitMessage+=("Found these python versions installed: ${msg}") for ((i=0;i<${#inst[@]};i++)); do [[ ${inst[$i]} == $versionChoice ]] && \ exitMessage+=("Version ${versionChoice} is already installed.") && \ printf '%s\n' "${exitMessage[@]}" && exit 0 done return 0 } #------------------------------------------------------------------------------ :<<'__comments__' Name: exit_handler Synopsis: If $? is non-zero print error message(s); the first statement to appear in main() must be: trap exit_handler EXIT Globals: errFuncName, exitMessage[@] Returns: 0 on normal exit; $? on error. __comments__ exit_handler() { local errNum="$?" [[ $errNum -eq 0 ]] && return 0 local errMsg errTxt errTxt="ERROR_${errNum}" errMsg="Error ${errNum}: No description available" [[ ${!errTxt} ]] && errMsg="Error ${errNum}: ${!errTxt}" [[ ${errFuncName} ]] && errMsg="${errFuncName}: ${errMsg}" errMsg="${0##*/}: ${errMsg}" [[ ${exitMessage[@]} ]] && printf '%s\n' "${exitMessage[@]}" printf '%s\n' "${errMsg}" return $errNum } #------------------------------------------------------------------------------ :<<'__comments__' Name: get_user_input Synopsis: If argList[0] is: missing, return 0; appears to be a valid python version number, set versionChoice and return 0; an invalid version number, append a message to exitMessage[@] and exit 18; is any other value, return 0. Globals: FUNCNAME[@], argList[@], errFuncName, exitMessage[@], versionChoice Returns: 0 on success; errFuncName, exitMessage[@] and exit 18 on error. __comments__ get_user_input() { [[ ${argList[@]} ]] || return 0 errFuncName="$FUNCNAME" [[ ${argList[0]} =~ ^[2-9]{1}\.[0-9]{1,2}\.[0-9]{1,2}$ ]] && \ versionChoice="${argList[0]}" && return 0 [[ ${argList[0]} =~ ^[0-9]{1,}.*$ ]] && \ exitMessage+=("${argList[0]} is not a valid python version number.") && \ exit 18 printf '%s\n' "Searching for Python version ${versionChoice}" return 0 } #------------------------------------------------------------------------------ :<<'__comments__' Name: get_version_choice Synopsis: Retrieve python versionChoice source archive from SRC_URL and save to SRC_PATH Globals: FUNCNAME[@], SRC_PATH, SRC_URL, errFuncName, exitMessage[@], versionChoice Requires: curl, id Returns: 0 on success; errFuncName, exitMessage[@] and exit 15, 22 or 255 on error. __comments__ get_version_choice() { [[ $versionChoice ]] || return 0 errFuncName="$FUNCNAME" type -p curl id &>/dev/null || exit 15 local admin msg retVal src src="${SRC_URL}/${versionChoice}/Python-${versionChoice}.tgz" msg="Checking for a source archive at ${SRC_URL}/${versionChoice}/" printf '%s\n' "${msg}" exitMessage+=("${msg}") # curl returns size and last mod time of $src or error if $src is not found curl -f -I -k -s -o /dev/null ${src} || { exitMessage+=("No file named Python-${versionChoice}.tgz was found.") exitMessage+=("Python version ${versionChoice} does not appear to exist.") exit 22 } msg="File named Python-${versionChoice}.tgz was found." printf '%s\n' "${msg}" exitMessage+=("${msg}") [[ $(id -u) -eq 0 ]] || admin="sudo" ${admin} curl -k -s -o ${SRC_PATH}/Python-${versionChoice}.tgz ${src} || { retVal="$?" msg="curl failed to retrieve the file and returned exit value ${retVal}." exitMessage+=("${msg}") exit 255 } return 0 } #------------------------------------------------------------------------------ :<<'__comments__' Name: install_required_rpms Synopsis: Install any RPM groups in RPM_GRP_DEPS[@] and RPM packages in RPM_PKG_DEPS[@] not already installed. Globals: FUNCNAME[@], RPM_GRP_DEPS[@], RPM_PKG_DEPS{@], errFuncName, exitMessage[@], pkgMgr, versionChoice Requires: id Returns: 0 on success; errFuncName, exitMessage[@] and exit 15 or 255 on error. __comments__ install_required_rpms() { [[ $versionChoice && $pkgMgr ]] || return 0 errFuncName="$FUNCNAME" type -p id &>/dev/null || exit 15 local admin i msg [[ $(id -u) -eq 0 ]] || admin="sudo" [[ ${RPM_GRP_DEPS[@]} ]] && { for ((i=0;i<${#RPM_GRP_DEPS[@]};i++)); do msg="Attempting to install or update RPM group '${RPM_GRP_DEPS[$i]}'" exitMessage+=("${msg}") msg="${msg} This may take a while. Please wait..." printf '%s\n' "${msg}" ${admin} ${pkgMgr} -y -q groupinstall --setopt tsflags=test \ "${RPM_GRP_DEPS[$i]}" && { ${admin} ${pkgMgr} -y -q groupinstall "${RPM_GRP_DEPS[$i]}" printf '%s\n' "Success! RPM group '${RPM_GRP_DEPS[$i]}' is installed." } || { msg="Failed to install or update RPM group '${RPM_GRP_DEPS[$i]}'" msg="${msg} Cannot continue." exitMessage+=("${msg}") exit 255 } done } [[ ${RPM_PKG_DEPS[@]} ]] && { for ((i=0;i<${#RPM_PKG_DEPS[@]};i++)); do msg="Attempting to install or update RPM package '${RPM_PKG_DEPS[$i]}'" exitMessage+=("${msg}") msg="${msg} Please wait..." printf '%s\n' "${msg}" ${admin} ${pkgMgr} -y -q install --setopt tsflags=test \ "${RPM_PKG_DEPS[$i]}" && { ${admin} ${pkgMgr} -y -q install "${RPM_PKG_DEPS[$i]}" printf '%s\n' "Success! RPM package '${RPM_PKG_DEPS[$i]}' is installed." } || { msg="Failed to install or update RPM package '${RPM_PKG_DEPS[$i]}'" msg="${msg} Cannot continue." exitMessage+=("${msg}") exit 255 } done } return 0 } #------------------------------------------------------------------------------ :<<'__comments__' Name: show_usage Synopsis: If versionChoice is not set display script usage. Globals: BASE_PATH, EXEC_NAME, FUNCNAME[@], SRC_PATH, SRC_URL, errFuncName, versionChoice Returns: 0 in all cases. __comments__ show_usage() { [[ $versionChoice ]] && return 0 errFuncName="$FUNCNAME" printf '%b' " ------------------------------------------------------------------------------- Usage: ${EXEC_NAME} ${0##*/} VERSION VERSION must be a valid Python version number (e.g. 3.8.4) otherwise this message will be displayed. This script will retrieve the source archive for Python VERSION from ${SRC_URL} and save it to ${SRC_PATH} then build and install Python VERSION at ${BASE_PATH} Attempts to build Python versions above 3.8.17 on RHEL|CentOS 7 will likely fail. ------------------------------------------------------------------------------- " return 0 } #------------------------------------------------------------------------------ # main #------------------------------------------------------------------------------ main() { trap exit_handler EXIT argList=("$@") errFuncName="$FUNCNAME" [[ ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -ge 2 ]] || \ [[ ${BASH_VERSINFO[0]} -gt 4 ]] || exit 10 [[ $EXEC_NAME && $EXEC_PID -eq $$ ]] || \ exitMessage+=("${0##*/} may not be executed directly.") [[ -t 1 ]] || exitMessage+=("Script must run interactively (in a terminal).") [[ ${exitMessage[@]} ]] && exit 250 declare -F build_version_choice check_distrover check_tools \ check_user_privilege check_versions_installed exit_handler get_user_input \ get_version_choice install_required_rpms show_usage &>/dev/null || exit 16 check_distrover check_tools check_user_privilege get_user_input check_versions_installed get_version_choice install_required_rpms build_version_choice show_usage exit 0 } #------------------------------------------------------------------------------ # run program #------------------------------------------------------------------------------ main "$@"