Updated Feb 2024: Starting properly on MacOS; Completion demo; "peek" find demo.
For some years, I've got by with a gradually evolving set of Emacs extensions and configuration to make me productive and happy while working with Python. I tend to be slow to adopt new tools once I've got something working. But my old setup was getting pretty crufty, and there's so much buzz around LSP that -- rather than yell at the VSCode kids to get off my lawn -- I wanted to see what I was missing.
As always, my complete emacs config lives on my github.
Thanks to the folks in the "tool talk" meeting at Recurse for inspiring me to finally do this.
I'm just starting to get comfortable with it, but here's where I'm at currently:
LSP
Emacs has good support for LSP and it was pretty easy to set up. I use straight to manage my Emacs packages, so all I had to do was this, adapted from the lsp-mode docs:
(straight-use-package 'lsp-mode)
(use-package lsp-ui
:commands lsp-ui-mode
:straight t)
(add-hook 'python-mode-hook 'lsp-deferred)
This didn't actually do anything until I had a language server installed. That's where things got thorny...
Per-buffer environment setup
Spoiler alert: we're going to use direnv! Skip to the next section if you're not curious about why.
The long-winded problem
To run an LSP server, you typically need to install the server in the
environment (eg. for python, something like venv, virtualenv, or pyenv) where
your "project" is being developed/run. This means running a command such as
pip install python-lsp-server
or pip install pyright
.
But... what about when you frequently work on multiple projects, each with their own environment? You don't want them to clash with each other, or you might run into trouble such as your language server being unable to find completions for code you know is in your project - because it's looking at the wrong project.
In order to find the right server, emacs needs to activate the right environment when working on relevant files, so that the PATH environment variable gets updated and Emacs can find the right server(s) to run.
Over the years, I've tried various Emacs extensions that are supposed to
help with working in virtual python environments: pyvenv
,
virtualenvwrapper
, and the like.
The problem is, they typically make changes to the global
environment for the entire Emacs editing session. This means I couldn't really
work on multiple projects at once.
And, each one of those Emacs modes is tied to a specific solution such as
virtualenv
, which makes it harder to switch between those tools - and doesn't
help with other languages.
Solution, part 1: Direnv
direnv is a really neat tool for the shell that supports automatically updating environment variables when a directory is entered.
For python, this means you can easily activate / deactivate a virtualenv just
by cd
ing into / out of a directory. That's pretty handy regardless of emacs!
I've been overdue for a tool like this for a long time.
It does require you to put .envrc
files in directories of interest, but they
are easy to write, and it seems worth the trouble.
Direnv has pre-built
recipes
for setting up a python 3 virtual env; I'll probably use that in the future,
but I have often lazily done python3 -m
venv
in the root of a project I'm working on, and I found that it was easy to support
my existing projects by dropping in the following .envrc
file:
test -f bin/activate && source bin/activate
Even better, direnv
is not at all Python-specific, so I will probably
leverage it when working in other languages too.
Solution, part 2: Envrc mode
There is of course an Emacs mode to integrate with direnv: envrc. The best thing? It's buffer-local!
This means I can open files in different projects, each with their own virtual
env, and emacs will see different PATH
etc. for each file I have open.
This means I can run correctly isolated LSP sessions for each project!
Setting it up turned out to be easy as well:
(straight-use-package 'envrc)
... with one gotcha, you have to do this after all other config (for reasons explained in the docs):
(envrc-global-mode)
Installing the LSP server and related tools for each project
I haven't automated this for new projects, but a basic recipe to start a new Python project would look something like:
mkdir my-new-project
cd my-new-project
git init .
# Set it up for direnv
echo "layout python3" > .envrc
direnv allow
# Congrats, you now have a virtual python 3 environment in .direnv/python-3.11
# and it's active already!
# You probably want to git ignore those.
# Now we need our python packages:
pip install python-lsp-server pylsp-mypy flake8
It took a bit of trial and error, but now it Just Works.
Fixing a MacOS environment issue
I found that when launching emacs via Spotlight, which is my preferred way on
the mac, it did not properly see my $PATH
because Spotlight doesn't use the
whole environment from your preferred shell.
This led to some weird behavior such as direnv loading the wrong version of Python.
For example, if I have an .envrc
file consisting of layout python3
, direnv
would apparently find the system Python 3.9 and look for (or bootstrap) a new
virtual environment for that version.
But when direnv was run in a login shell,
it would find the python 3 version I'd installed via homebrew, eg 3.12, and use
that. So I'd have all my packages such as python-lsp-server
installed for
3.12. Then when Emacs looked for the pylsp
executable, it was wrongly looking in the
bin directory for 3.9. So LSP would fail.
Solution: inspired by a stackoverflow post, I make an Automator script to launch Emacs.
- Launch Automator
- Select "Application" type
- Choose "Run Shell Script"
- Paste in this source:
EMACSDIR="$(dirname $(dirname $(readlink -f /opt/homebrew/bin/emacs)))"
bash --login -c "$EMACSDIR/bin/emacs"
- Then save this as Emacs.app in
/Applications
The first line attempts to get the path to wherever Homebrew put emacs,
regardless of specific version number.
The second line starts a subshell runs bash in login mode, thus loading my .bash_profile
and setting $PATH.
Now when I run emacs via spotlight, any commands it launches should work exactly the same as in my shell.
What does all this buy you?
I'm still exploring the features of LSP, but I'm liking these basic IDE-like features. Some of them I had before via other means, but this is already nicer:
Completion
This didn't take any configuration, it Just Worked.
(I'm unclear exactly how since TAB
still is bound to
py-indent-or-complete
from python-mode.el
, but it's great.)
Find references, find definition
Find references is bound by default to s-l g r
.
On my mac, I have Command
as super
.
Even nicer is the "peek" interface via s-l G r
:
Find definition is likewise bound to s-l g g
, or s-l G g
for the "peek" version.
This replaces language-specific mode features such as jedi:goto-definition
,
or my previous favored solution of using Emacs'
xref and tags functionality.
Xref was useful because it's fast in some huge repos I had to work
with. LSP seems better at finding the actual definition; xref often showed me a bunch
of false positives. And, now I never have to generate a TAGS file again, hopefully.
We'll see how well LSP holds up next time I have to work on a giant monolith ...
One thing I really miss from xref though; it had a "back" button, AKA
xref-pop-marker-stack
, bound to M-,
which would take me back where I
was before following the definition. lsp-mode doesn't seem to have that :-(
Inline documentation on hover!
Nice.
Style errors / warnings
I had this without LSP via flycheck
but it didn't always find the right executables; that's solved now thanks to
direnv, yay, as long as flake8
or another compatible linter is installed in the environment.
The way errors are currently displayed inline is a little weird for me, I may look into how to tweak that, but it's usable already.
Type error hints via MyPy
These are handy! Just need to have pylsp-mypy
installed in the environment.
Reformatting via plugins
This one tripped me up at first because it turns out they are off by default as per the docs. For pylsp, you have to explicitly enable one of the plugins. So for example, to use black for python, install the plugin in your shell:
pip install python-lsp-black
And then configure it in emacs:
(setq lsp-pylsp-plugins-black-enabled 't)
Class / method / function breadcrumbs at top of the window.
I had this without LSP via which function mode but I like the breadcrumb style better.
More things to try in future
-
Debugging. I am still a dinosaur stepping through things in iTerm, but look at these pretty screenshots.
-
Helm
integration. I love Helm and am curious what helm-lsp has to offer. -
Call tree visualization via lsp-treemacs
References
Other noteworthy blog posts that inspired me:
- https://www.bassamsaeed.ca/posts/python-development-in-emacs/
- https://taingram.org/blog/emacs-lsp-ide.html
- https://www.evalapply.org/posts/emerging-from-dotemacs-bankruptcy-ide-experience/ from Recurse colleague Aditya
- An opposite approach (no LSP, mostly vanilla): https://robbmann.io/posts/006_emacs_2_python/