Getting LSP servers and other tools to work in venvs in GNU Emacs
Over on the Fediverse I had a question:
GNU Emacs people who write Python using venvs, what's the state of the art for getting linters, LSP servers, and so on started with the right paths/etc set up for a particular project's venv? Is it (still) direnv + the envrc package, or something else (.dir-locals.el with a exec-path setting, maybe)?
For my sins, straightforward venv activation stuff won't work in my GNU Emacs + Python setup because often I'm developing code outside of the venv with the supporting packages for that code. For reasons, yes I know this is odd, it's our (my) way.
After playing around with this for a bit I think I have a recipe that works, although I'm not sure it's completely correct.
To start, you want to do the brute force thing and install copies of every LSP server and tool you want to use into your project's virtual environment. This isn't absolutely required for some tools, but we're already throwing disk space at the problem (that's partly what venvs are about) and it's the easiest way to make Python-based tools like the Python LSP server happy.
Then we have two choices. First, you can painstakingly activate the virtual environment every time before you run GNU Emacs to edit files in the project. This will set $PATH and $VIRTUAL_ENV, picking up and generally configuring your installed tools properly, but speaking from personal experience this is kind of a pain (plus it means you may need to run multiple instances of GNU Emacs), and sooner or later I forget. Second, you can use direnv and the GNU Emacs envrc package to automatically configure things in the relevant buffers.
To use the direnv based approach, each project's root needs a
.envrc file that exports a $VIRTUAL_ENV with the appropriate top
level venv directory and a $PATH that puts the venv's bin/
directory on it (probably first). Then you enable that .envrc in
direnv, install envrc into GNU Emacs from MELPA, and add
something like the following to your .emacs:
(use-package envrc :defer t :if (executable-find "direnv") :hook (python-mode . envrc-mode) (python-ts-mode . envrc-mode) )
You can set envrc's global mode if you want, but I opted to be narrow. If I want environment variables set outside of editing Python files, I'll enable it in more modes.
So far, all of this has worked for me in GNU Emacs. The projects (ie, directory trees) where I have a .envrc set up run the right versions of LSP server(s) and tools, and I can edit files from multiple projects in the same Emacs session and have everything work out because envrc makes these settings buffer-local, so each project can get the right LSP server and other settings.
Setting $PATH and $VIRTUAL_ENV has a varying set of effects. For
Python based tools installed in the venv, it means that they'll run
from the venv using the venv's Python and the venv's Python packages
available to be introspected and so on; this covers pylsp and mypy
(and I think basedpyright if you want to use a
very strict LSP server). The effects on
LSP servers not written in Python and
other tools varies. Pyrefly currently
determines the Python import path by running the 'python3' that's on
your $PATH and dumping its 'sys.path', while ty looks at $VIRTUAL_ENV (cf).
(Currently, I believe that ruff can be installed globally because as far as I can tell, it doesn't look at Python imports at all. Installing it into your venv is relatively cheap insurance, though.)
In theory you can get some of this by merely setting $PYTHONPATH
to <venv>/lib/python3.NN/site-packages, but I wouldn't trust that to
be fully functional and without side effects. It's better to set the
environment variables in your .envrc, which more or less duplicates
what the venv 'activate' script does. I have set $PYTHONPATH in
addition to the other variables as the easy way to make a small
pipx-based venv work (in a simple case) without having to inject a
bunch of additional tools into it.
(All of this is unlikely to work remotely over Tramp.)
PS: You may or may not want to hook direnv up to your shell. I
haven't, partly because it would probably be a lot of work to make it
work in my unusual shell. Probably the easiest output format to deal
with (or read) is 'direnv export systemd'.
PPS: When setting VIRTUAL_ENV in your .envrc, remember to set it
as 'export VIRTUAL_ENV=...' because it probably doesn't already
exist in the environment. As the direnv documentation says in passing,
your .envrc is loaded into a Bash subshell, so it can contain any
valid Bash stuff and it needs to explicitly export (new) environment
variables you want direnv to pick up.