How to Maintaining Python environments with Ansible?
A while ago, I settled on pyenv
to centralize my Python virtual environment management. I documented the setup in my previous post. However, when I had to configure a new machine, I realized I was repeating the same steps manually. To save time and maintain consistency, I decided to automate this setup using Ansible.
Automation ensures that the configuration remains consistent across systems and idempotent—meaning no matter how many times I run it, the system always ends up in the same state.
Before diving into the Ansible playbook, let’s first discuss why scripts alone are not the best approach.
Why Not Just Use a Script?
Shell scripts don’t inherently track system state, which leads to several issues:
- Running the script multiple times can cause conflicts such as duplicate environment variables or broken installations.
- Failures leave the system in an inconsistent state which often can requiring manual intervention.
- Handling edge cases means adding more logic making the script harder to maintain and difficult to write.
A configuration management tool like Ansible eliminates these problems by ensuring the system reaches the desired state automatically and idempotently.
Why Ansible?
Idempotency means applying a change only if it hasn’t been applied before. Running the same Ansible playbook multiple times won't introduce unintended modifications. This makes Ansible superior to traditional shell scripts for system configuration.
Now, let’s look at my first version of the playbook.
Initial Ansible Playbook for pyenv
Setup
Here’s my first attempt at automating the installation of pyenv
and pyenv-virtualenv
using Ansible:
- name: Setup Pyenv and Pyenv-Virtualenv hosts: localhost become: yes tasks: - name: Install system dependencies apt: name: - git - curl - build-essential - libssl-dev - zlib1g-dev - libbz2-dev - libreadline-dev - libsqlite3-dev - wget - llvm - libncurses5-dev - xz-utils - tk-dev - libffi-dev - liblzma-dev - python3-venv state: present update_cache: yes - name: Clone pyenv repository git: repo: "https://github.com/pyenv/pyenv.git" dest: "{{ ansible_env.HOME }}/.pyenv" update: no - name: Clone pyenv-virtualenv repository git: repo: "https://github.com/pyenv/pyenv-virtualenv.git" dest: "{{ ansible_env.HOME }}/.pyenv/plugins/pyenv-virtualenv" update: no - name: Source pyenv configuration in .bashrc lineinfile: path: "{{ ansible_env.HOME }}/.bashrc" line: '[[ -f ~/.pyenv_config ]] && source ~/.pyenv_config' insertafter: EOF state: present
Issues in This Playbook
This playbook had two major issues:
1. Installing pyenv
as Root Instead of the Current User
Since become: yes
was applied globally, Ansible executed all tasks as root
. This meant pyenv
was installed in /root/.pyenv
instead of the actual user’s home directory.
2. Duplicate .bashrc
Modifications
Each run of this playbook appended the sourcing command to .bashrc
, leading to redundant lines.
2. Sourcing Pyenv in .bashrc
Another tricky idempotency problem arose when trying to ensure pyenv
was sourced in .bashrc
. Since this code block simply inserts the following line at the end of .bashrc
without checking for previous entries, idempotency was lost:
- name: Source pyenv configuration in .bashrc (if not already sourced) lineinfile: path: "{{ ansible_env.HOME }}/.bashrc" line: '[[ -f ~/.pyenv_config ]] && source ~/.pyenv_config' insertafter: EOF state: present
This was fixed using blockinfile
with a marker
, which tracks the previous entry and only updates .bashrc
conditionally:
- name: Ensure Pyenv configuration is added to .bashrc with markers blockinfile: path: ~/.bashrc marker: "# START PYENV CONFIGURATION" insertafter: EOF block: | # Pyenv configuration [[ -f ~/.pyenv_config ]] && source ~/.pyenv_config
Benefits of blockinfile
with marker
over lineinfile
-
Prevents duplication: Unlike
lineinfile
, which only inserts a single line,blockinfile
ensures that an entire block is managed as a unit, preventing redundant insertions. - Easier maintenance: Using markers allows easy identification and modification of configurations later.
-
Ensures idempotency:
blockinfile
keeps track of whether the block has already been added, avoiding unnecessary modifications.
Final Ansible Playbook
- name: Install and Configure Pyenv hosts: localhost tasks: - name: Install system dependencies become: yes apt: name: - git - curl - build-essential - libssl-dev - zlib1g-dev - libbz2-dev - libreadline-dev - libsqlite3-dev - wget - llvm - libncurses5-dev - xz-utils - tk-dev - libffi-dev - liblzma-dev - python3-venv state: present update_cache: yes - name: Clone pyenv repository git: repo: "https://github.com/pyenv/pyenv.git" dest: "{{ ansible_env.HOME }}/.pyenv" update: no - name: Clone pyenv-virtualenv repository git: repo: "https://github.com/pyenv/pyenv-virtualenv.git" dest: "{{ ansible_env.HOME }}/.pyenv/plugins/pyenv-virtualenv" update: no - name: Ensure Pyenv configuration is added to .bashrc blockinfile: path: ~/.bashrc marker: "# START PYENV CONFIGURATION" insertafter: EOF block: | # Pyenv configuration export PYENV_ROOT="$HOME/.pyenv" command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH" eval "$(pyenv init --path)" eval "$(pyenv init -)" eval "$(pyenv virtualenv-init -)"
This final playbook ensures a clean, idempotent, and repeatable setup for managing Python environments with pyenv
using Ansible. Thanks for reading this far.