Skip to main content

Emacs: Manage Python virtual environments using `pyvenv`

elpy is a great minor-mode for python-mode in Emacs, but managing multiple virtual environments has always been tricky as pyvenv (that elpy uses) doesn't have a way to have buffer-local venv -- any venv activated using e.g. pyvenv-activate/pyvenv-workon is (process) global.

Below is a tinyelisp recipe to make switching between venvs easier when working on multiple projects. The recipe assumes:

  • the concept of "generic" venvs i.e. all the virtual environments that are not related to any particular project
  • all the generic venvs are stored in a python_<version> directory under a common base directory -- e.g. Python 3.11 venv directory could be /home/user/generic_venvs/python_3.11 and for Python 3.12 that should be /home/user/generic_venvs/python_3.12.

The recipe iteself:

(defun local-python-venv-activate (&optional emacs-python-venv generic-venv-dir-prefix generic-venv-python-version only-return)
  "Activate and return the path to venv root to be used in `python-mode'.

EMACS-PYTHON-VENV is either a full path to the venv root or a partial string like
<major>.<minor> e.g. 3.12. In the latter case generic venv for that Python version
would be used. The generic venv base directory should contain the different Python
versions e.g. `/path/to/base_dir/python_3.12'; the GENERIC-VENV-DIR-PREFIX in this
case would be `/path/to/base_dir/python_', and the GENERIC-VENV-PYTHON-VERSION
would be `3.12'. If EMACS-PYTHON-VENV is not passed, the env var `EMACS_PYTHON_VENV'
 is tried.  Otherwise, Python 3.11 is used as the default.

If the value of ONLY-RETURN is non-nil, only return the path to venv root without
activating it."

  (interactive)
  (let ((emacs-python-venv (or emacs-python-venv (getenv "EMACS_PYTHON_VENV")))
        (generic-venv-dir-prefix (or generic-venv-dir-prefix "/home/user/generic_venvs/python_"))
        (generic-venv-python-version (or generic-venv-python-version "3.11")))

    (cond ((not emacs-python-venv)
           (setq emacs-python-venv (concat generic-venv-dir-prefix generic-venv-python-version)))
          ((string-match-p "^[[:digit:]]+\\.[[:digit:]]+$" emacs-python-venv)
           (setq emacs-python-venv (concat generic-venv-dir-prefix emacs-python-venv))))

    (unless only-return
      (message "Activating local Python venv: %s" emacs-python-venv)
      (pyvenv-activate emacs-python-venv))
    emacs-python-venv))

I've added the above as :after python-mode advice as I have many other stuff after a python-mode is created. But adding as a hook in the python-mode-hook list would be fine as well (actually preferable in most cases).

There's a caveat though -- as the elpy-shell-send-region-or-buffer (C-c C-c) command opens up a new inferior python process (inferior-python-mode) so the above function would run if added as a python-mode-hook or in a python-mode :after advice function. To tackle this, we can run the above local-python-venv-activate function when the current major mode is not inferior-python-mode (or any mode derived from inferior-python-mode):

(unless (derived-mode-p 'inferior-python-mode)
        (local-python-venv-activate))

To use in a hook function, we need to wrap the above in a lambda or a named function (recommended) e.g.:

(defun local-python-venv-activate-hook ()
  (unless (derived-mode-p 'inferior-python-mode)
          (local-python-venv-activate)))

(add-hook 'python-mode-hook #'local-python-venv-activate-hook)

Now, I tend to have the default generic venv activated always when I am playing around. For specific projects, I have the venv path as the value of env var EMACS_PYTHON_VENV in the project root's .dir-locals.el. By default the env vars are process-global but as Emacs saves the env vars in the list variable process-environment, we can make the variable buffer-local and then set the env var. Eventually, as all the variables declared in .dir-locals.el are dir-local, which in-turn makes them file-local and buffer-local -- the env var EMACS_PYTHON_VENV would only be used for all the python-mode buffers in the current project.

For example, if the project's venv dir is /home/user/project/venv, the following can be added to the project root's .dir-locals.el i.e. in the /home/user/project/.dir-locals.el:

((python-mode . ((eval . (progn
                           (make-local-variable 'process-environment)
                           (setq process-environment (copy-sequence process-environment))
                           (setenv "EMACS_PYTHON_VENV" "3.11"))))))

As variables/forms defined in .dir-locals.el applies to all files in the directory and all sub-directories, the above set the env var EMACS_PYTHON_VENV TO 3.11 for any file visiting buffer opened in python-mode i.e. any .py file in /home/user/project/ dir and any subdir.

(The .dir-locals.el should be added to the global .gitignore to avoid adding personal preferences to the project repository.)


I've created a separate venv for packages not related to any particular project i.e. applicable for all projects; these packages are mostly related to debugging, formatting stuff. This venv contains the following packages:

black
elpy
flake8
ipdb
ipython
isort
jedi
mypy

As we can't have two venvs activated at the same time, I've used the PYTHONPATH env var to point to this common venv. In my ~/.emacs/init.el, I have the following:

(setenv "PYTHONPATH" "/path/to/common/venv/lib/python3.11/site-packages")

This would add /path/to/common/venv/lib/python3.11/site-packages to the module search path -- sys.path.


So far so good! But there is still another issue that needs attention -- when switching between existing buffers with different virtual environments, Emacs keeps the last activated virtual environment as current regardless of the desired venv for the current buffer.

To fix this, we can leverage the window-selection-change-functions list -- any function added to this list would be run whenever buffer is changed in any window inside a frame. Here's something I came up with:

(defun local-python-set-correct-venv-on-window-buffer-change (_frame)
  "Activate the venv applicable for the current (existing) `python-mode' buffer."
  (let* ((current-buffer (get-buffer (buffer-name)))
         (current-buffer-major-mode (buffer-local-value 'major-mode current-buffer)))
    (when (equal current-buffer-major-mode 'python-mode)
      ;; Don't re-activate the same venv
      (unless (string= (string-trim (or pyvenv-virtual-env "") nil "/+") (string-trim (local-python-venv-activate nil nil nil t) nil "/+"))
        ;; Kill all inferior Python shell buffer processes (no need to kill the whole buffer)
        (let ((inferior-python-buffer-regex-pattern "^\\*Python\\(?:\\[[^]]*]\\)?\\*$")
              (inferior-python-buffer-process nil))
          (dolist (buffer (buffer-list))
            (when (string-match-p inferior-python-buffer-regex-pattern (buffer-name buffer))
              (setq inferior-python-buffer-process (get-buffer-process buffer))
              (when inferior-python-buffer-process
                (kill-process inferior-python-buffer-process)))))
        (local-python-venv-activate)))))

Adding the above to the window-selection-change-functions list:

(add-to-list 'window-selection-change-functions #'local-python-set-correct-venv-on-window-buffer-change)

Phew!

If I can summarize the workflow:


Comments

Comments powered by Disqus