Python & Emacs, 2025

2025-08-27

Here we go with installment four of the unofficial series (first, second, third). I've decided I'll start putting the year in the title.

Back in 2022 python-base-mode was added to Emacs such that (a) python-mode and python-ts-mode can inherit a lot of shared functionality, and (b) users don't have to account for customizing both modes (when their configuration needs to be used in both tree-sitter and non-tree-sitter enabled Emacs builds). It's a good idea to use the generic major mode when possible.

These days I'm on the Astral train, which really means ruff and uv (and ty, sort of, since they're not done yet).

Unfortunately ruff isn't able to run both format and the import sorting mechanism of the linter (ruff check) in the same command. So I have a couple of reformatters defined to handle both steps:

(use-package reformatter
  :ensure t
  :config
  (reformatter-define dd/ruff-format
    :program "uvx"
    :args `("ruff" "format" "--stdin-filename" ,buffer-file-name "-"))
  (reformatter-define dd/ruff-sort
    :program "uvx"
    :args `("ruff" "check" "--select" "I" "--fix" "--stdin-filename" ,buffer-file-name "-")))

The default virtual environment path for a project managed with uv is just .venv. Combining that behavior with my reformatters, I get a simple "init" function for all of my Python buffers. The function automatically finds .venv in the project and calls pyvenv-activate (from the pyvenv package); it also enables the reformatters:

(defun dd/python-init ()
  (let* ((project (project-current))
         (project-root (when project (project-root project)))
         (venv-path (when project-root
                      (expand-file-name ".venv" project-root))))
    (when (and venv-path (file-directory-p venv-path))
      (make-local-variable 'pyvenv-virtual-env)
      (pyvenv-activate venv-path))
    (dd/ruff-format-on-save-mode +1)
    (dd/ruff-sort-on-save-mode +1)))

(add-hook 'python-base-mode-hook #'dd/python-init)

The reason I need to "activate" the virtual environment in Emacs is because I typically install whatever language servers I want to play with in the virtual environment of a project. Then, invoking M-x eglot will give us choices based on whatever eglot finds (through its executable-find usage in eglot-alternatives calls within eglot-server-programs). My Python incantation:

(add-to-list 'eglot-server-programs
             `(python-base-mode
               . ,(eglot-alternatives '(("basedpyright-langserver" "--stdio")
                                        ("ty" "server")
                                        ("pyright-langserver" "--stdio")))))

You'll notice that I cycle through using basedpyright, ty, and pyright.

I'm hopeful that sometime in the near future my eglot-alternatives list can be redefined to

(add-to-list 'eglot-server-programs
             `(python-base-mode
               . ,(eglot-alternatives '(("uv" "run" "basedpyright-langserver" "--stdio")
                                        ("uv" "run" "ty" "server")
                                        ("uv" "run" "pyright-langserver" "--stdio")))))

such that I can rely on uv's automatic virtual environment discovery/usage, removing the need to have code that activates a virtual environment. The current eglot-alternatives implementation in Emacs uses the first entry of the list you provide to pass along to completing-read, and completing-read ignores duplicates (and all entries are duplicates in the list above). I filed 79290; maybe it'll go somewhere eventually, but it's not a big deal.