diff --git a/.bashrc b/.bashrc index 0549e39..24a1498 100644 --- a/.bashrc +++ b/.bashrc @@ -156,6 +156,13 @@ if ! shopt -oq posix; then fi +# Enable completions for various tools +command_exists invoke && eval "$(invoke --print-completion-script=bash)" +command_exists nox && eval "$(register-python-argcomplete nox)" +command_exists pip && eval "$(pip completion --bash)" +command_exists pipx && eval "$(register-python-argcomplete pipx)" +command_exists poetry && eval "$(poetry completions bash)" + # ============ # Key Bindings @@ -234,5 +241,22 @@ _prompt_jobs() { (( $(jobs -rp | wc -l) )) && echo -e "\033[0;32m %\033[0m" } -PS1='${chroot:+($_debian_chroot)}\w$(_prompt_git)$(_prompt_jobs) > ' +# Mimic zsh's pyenv/venv integration +_prompt_pyenv() { + if [ -n "$VIRTUAL_ENV" ]; then + echo -e "\033[0;36m py $(python -c "import os, sys; (hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)) and print(os.path.basename(sys.prefix))")\033[0m" + elif [ -n "$PYENV_VERSION" ]; then + if [ "$PYENV_VERSION" != "system" ]; then + echo -e "\033[0;36m py $PYENV_VERSION\033[0m" + fi + elif [ -f "$(pwd)/.python-version" ]; then + echo -e "\033[0;36m py $(cat .python-version | sed ':a;N;$!ba;s/\n/:/g')\033[0m" + fi +} + +# Disable the default prompts set by pyenv and venv +export PYENV_VIRTUALENV_DISABLE_PROMPT=1 +export VIRTUAL_ENV_DISABLE_PROMPT=1 + +PS1='${chroot:+($_debian_chroot)}\w$(_prompt_git)$(_prompt_jobs)$(_prompt_pyenv) > ' PS2='... ' diff --git a/.config/pypoetry/config.toml b/.config/pypoetry/config.toml new file mode 100644 index 0000000..53b35d3 --- /dev/null +++ b/.config/pypoetry/config.toml @@ -0,0 +1,3 @@ +[virtualenvs] +create = true +in-project = true diff --git a/.config/shell/aliases.sh b/.config/shell/aliases.sh index bec47d9..7dafcb1 100644 --- a/.config/shell/aliases.sh +++ b/.config/shell/aliases.sh @@ -80,6 +80,23 @@ alias more='less' alias tree='tree -C --dirsfirst' +# Make working with Python more convenient + +alias py='python' +alias ipy='ipython' + +if command_exists poetry; then + alias pr='poetry run' +fi + +if command_exists pyenv; then + alias pyvenvs='pyenv virtualenvs --bare --skip-aliases' + alias pyver='pyenv version' + alias pyvers='pyenv versions --skip-aliases' + alias pywhich='pyenv which' +fi + + # Aliases for various utilities alias datetime='date +"%Y-%m-%d %H:%M:%S %z (%Z)"' alias datetime-iso='date --iso-8601=seconds' diff --git a/.config/shell/init_dotfiles.sh b/.config/shell/init_dotfiles.sh index c7a1749..18d84fc 100755 --- a/.config/shell/init_dotfiles.sh +++ b/.config/shell/init_dotfiles.sh @@ -20,7 +20,7 @@ git clone --bare git@git.webartifex.biz:alexander/dotfiles.git "$HOME/.dotfiles" # Backup old dotfiles rm -rf "$HOME/.dotfiles.bak" >/dev/null -mkdir -p $HOME/.dotfiles.bak/.config/{bat,flameshot,git,Nextcloud,pop-system-updater,psql,shell} && \ +mkdir -p $HOME/.dotfiles.bak/.config/{bat,flameshot,git,Nextcloud,pop-system-updater,psql,pypoetry,shell} && \ mkdir -p $HOME/.dotfiles.bak/.vim/{after/ftplugin,backup,swap,undo} && \ /usr/bin/git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME checkout 2>&1 | egrep "\s+\." | awk {'print $1'} | \ xargs -I{} mv {} "$HOME/.dotfiles.bak"/{} diff --git a/.config/shell/utils.sh b/.config/shell/utils.sh index ab3ccd4..92f22b1 100644 --- a/.config/shell/utils.sh +++ b/.config/shell/utils.sh @@ -12,6 +12,16 @@ in_zsh() { [ -n "$ZSH_VERSION" ] } +# Prepend a folder to $PATH if it is not already there +_prepend_to_path () { + if [ -d "$1" ] ; then + case :$PATH: in + *:$1:*) ;; + *) PATH=$1:$PATH ;; + esac + fi +} + # ========================= @@ -30,6 +40,13 @@ fi command_exists lesspipe && eval "$(SHELL=/bin/sh lesspipe)" +# Initialize pyenv if it is installed +if command_exists pyenv; then + eval "$(pyenv init -)" + eval "$(pyenv virtualenv-init -)" +fi + + # Configure the keyboard: # - make right alt and menu keys the compose key, e.g., for umlauts # - make caps lock a ctrl modifier and Esc key @@ -38,6 +55,32 @@ command_exists xcape && xcape -e "Caps_Lock=Escape" +# ========================== +# Command not found handlers +# ========================== + + +# Check if an unknown command is in a local Python venv +command_not_found_handle() { + if [ -x ".venv/bin/$1" ]; then + echo 'You forgot to activate the virtualenv' 1>&2 + exe=".venv/bin/$1" + shift + "$exe" "$@" + return $? + else + echo "$1: command not found" 1>&2 + return 127 + fi +} + +# zsh uses another name for the handler +command_not_found_handler() { + command_not_found_handle "$@" +} + + + # ============================== # Working with files and folders # ============================== @@ -194,6 +237,89 @@ genemail() { +# =================================================== +# Set up & maintain the Python (develop) environments +# =================================================== + + +# TODO: This needs to be updated regularly (or find an automated solution) +# The Python versions `pyenv` creates (in descending order) +_py3_versions=('3.10.5' '3.9.13' '3.8.13' '3.7.13') +_py2_version='2.7.18' + +# Each Python environment uses its own `poetry` installation to avoid +# integration problems between `pyenv` and `poetry` +# Source: https://github.com/python-poetry/poetry/issues/5252#issuecomment-1055697424 +_py3_site_packages=('poetry') + +# The pyenv virtualenv "utils" contains some globally available tools (e.g., `mackup`) +_py3_utils=('mackup') + +install-pyenv() { + echo -e "\nInstalling pyenv\n" + + # The official installer does a bit more than the `git clone`s below + # `curl https://pyenv.run | bash` + git clone https://github.com/pyenv/pyenv.git "$HOME/.pyenv" + git clone https://github.com/pyenv/pyenv-doctor.git "$HOME/.pyenv/plugins/pyenv-doctor" + git clone https://github.com/pyenv/pyenv-update.git "$HOME/.pyenv/plugins/pyenv-update" + git clone https://github.com/pyenv/pyenv-virtualenv.git "$HOME/.pyenv/plugins/pyenv-virtualenv" + git clone https://github.com/pyenv/pyenv-which-ext.git "$HOME/.pyenv/plugins/pyenv-which-ext" + + # On a first install, "$PYENV_ROOT/bin" is NOT on the $PATH + _prepend_to_path "$PYENV_ROOT/bin" +} + +re-install-pyenv() { + echo -e "\nRemoving pyenv\n" + rm -rf "$HOME/.pyenv" >/dev/null + install-pyenv +} + +create-or-update-python-envs() { + command_exists pyenv || install-pyenv + + eval "$(pyenv init --path)" + + # Keep a legacy Python 2.7, just in case + echo -e "\nInstalling/updating Python $_py2_version\n" + pyenv install --skip-existing $_py2_version + PYENV_VERSION=$_py2_version pip install --upgrade pip setuptools + PYENV_VERSION=$_py2_version python -c "import sys; print sys.version" + + for version in ${_py3_versions[@]}; do + echo -e "\nInstalling/updating Python $version\n" + pyenv install --skip-existing $version + + # Start the new environment with the latest `pip` and `setuptools` versions + PYENV_VERSION=$version pip install --upgrade pip setuptools + + # Put the specified utilities in the fresh environments (or update them) + for lib in ${_py3_site_packages[@]}; do + PYENV_VERSION=$version pip install --upgrade $lib + done + done + + # Create a virtualenv based off the latest Python version to host global utilities + echo -e "\nInstalling/updating global Python utilities\n" + pyenv virtualenv $_py3_versions[1] 'utils' + PYENV_VERSION='utils' pip install --upgrade pip setuptools + for util in ${_py3_utils[@]}; do + PYENV_VERSION='utils' pip install --upgrade $util + done + + # Create a virtualenv based off the latest Python version for interactive usage + # (This virtualenv is empty and is the target of accidental `pip install`s) + echo -e "\nInstalling/updating the default/interactive Python environment\n" + pyenv virtualenv $_py3_versions[1] 'interactive' + PYENV_VERSION='interactive' pip install --upgrade pip setuptools + + # Put all Python binaries and the utilities on the $PATH + pyenv global 'interactive' $_py3_versions 'utils' $_py2_version +} + + + # ============================= # Automate the update machinery # ============================= @@ -278,6 +404,28 @@ update-zsh() { } +# Update the entire Python tool chain +update-python() { + echo -e '\nUpdating the Python tool chain\n' + + if command_exists pyenv; then + echo -e '\nUpdating pyenv\n' + pyenv update + echo + + echo -e '\nUpdating Python environments\n' + create-or-update-python-envs + echo + fi + + if command_exists zsh-pip-cache-packages; then + echo -e '\nUpdating pip packages cache\n' + zsh-pip-clear-cache + zsh-pip-cache-packages + fi +} + + # Wrapper to run several update functions at once update-machine() { sudo --validate || return @@ -295,9 +443,14 @@ update-machine() { remove-old-snaps fi + update-python + update-dotfiles update-zsh + echo -e '\nUpdating the configs managed by mackup' + mackup restore --force + echo -e '\nUpdating password store\n' pass git pull echo diff --git a/.profile b/.profile index 2681a9a..640dc09 100644 --- a/.profile +++ b/.profile @@ -34,6 +34,11 @@ export BAT_CONFIG_PATH="$HOME/.config/bat/config" export LESSHISTFILE="${XDG_CACHE_HOME:-$HOME/.cache}/.lesshst" +export PYENV_ROOT="$HOME/.pyenv" +_prepend_to_path "$PYENV_ROOT/bin" +# No need for *.pyc files on a dev machine +export PYTHONDONTWRITEBYTECODE=1 + export PSQLRC="$HOME/.psqlrc" diff --git a/.zshrc b/.zshrc index 0e5b910..eabedc4 100644 --- a/.zshrc +++ b/.zshrc @@ -102,7 +102,10 @@ zplug "plugins/command-not-found", from:oh-my-zsh zplug "plugins/dotenv", from:oh-my-zsh zplug "plugins/dirhistory", from:oh-my-zsh zplug "plugins/git-escape-magic", from:oh-my-zsh +zplug "plugins/invoke", from:oh-my-zsh # completions for `invoke` zplug "plugins/jsontools", from:oh-my-zsh +zplug "plugins/pip", from:oh-my-zsh # completions for `pip` +zplug "plugins/poetry", from:oh-my-zsh # completions for `poetry` zplug "plugins/z", from:oh-my-zsh zplug "romkatv/powerlevel10k", as:theme, depth:1 @@ -141,6 +144,21 @@ zstyle ':completion:*:warnings' format 'No matches for: %d' zstyle ':completion:*' group-name '' +# Enable completions for various tools + +# invoke -> see plugins above +# command_exists invoke && eval "$(invoke --print-completion-script=zsh)" + +command_exists nox && eval "$(register-python-argcomplete nox)" + +# pip -> see plugins above +# command_exists pip && eval "$(pip completion --zsh)" + +command_exists pipx && eval "$(register-python-argcomplete pipx)" + +# poetry -> see plugins above + + # ============ # Key Bindings diff --git a/README.md b/README.md index 3625b50..1d262f1 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,21 @@ Furthermore, `zsh` is set up with [`oh-my-zsh`](https://ohmyz.sh/) and `zplug`. Otherwise, `~/.profile` is probably *not* sourced. Don't worry: Your current dotfiles are backed up in the `~/.dotfiles.bak` folder! + + +### Python Development Environments + +The develop environments for Python are managed by [`pyenv`](https://github.com/pyenv/pyenv). + +To set them up, run: + +```bash +install-pyenv && create-or-update-python-envs +``` + +Several Python binaries are installed. +Additionally, two `virtualenv`s, "interactive" and "utils", are also created: + - "interactive" is the default environment with *no* libraries installed, and + - "utils" hosts globally available utilities. + +Use `pyenv local ...` to specify a particular Python binary for a project.