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
  1. Prevents duplication: Unlike lineinfile, which only inserts a single line, blockinfile ensures that an entire block is managed as a unit, preventing redundant insertions.
  2. Easier maintenance: Using markers allows easy identification and modification of configurations later.
  3. 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.