My Experience with Xonsh

Since January I've been using Xonsh as my main shell on both my work and personal machines. In this post I will describe what I liked and what I didn't. TLDR; I liked that I found myself scripting more under Xonsh, but it can be slow and inconvenient as a shell so I'm switching back to bash/zsh.

The Good

What's great about Xonsh is you can switch between Python and Bash syntax within the same command. Xonsh is a handy tool when you don't know the traditional shell equivalent. If your Bash-foo is weak you can use Python as a crutch.

$ files = $(ls)
$ for file in files:
      fancy_func(file.strip())

Here's a meaningless example that calls a function on all the filenames returned by ls. This is easy to remember since there's no esoteric for-loop syntax. That being said, you can already see some pain points.

  1. We're using a for loop as a replacement for xargs, this is more typing.
  2. We call strip to remove the newline characters from each file.
  3. We're writing multiple lines.

Colors / Prompt / Autocomplete

These were great, but they don't make Xonsh special.

Readability

Python is more readable than Bash, so too is Xonsh. Here are some examples from my rc file. Some of them make a lot of sense!

Set an environment variable to a string

$EDITOR='nvim'

Add directories to the start of your path. $PATH can be accessed like a Python list.

for new_path in [
    "/home/hpincket/.local/bin",
    "/home/hpincket/scripts",
    "/home/hpincket/.cargo/bin",
    ]:
    $PATH.insert(0, new_path)

This makes more sense than your typical bash equivalent:

$PATH="/home/hpincket/scripts:$PATH"

Use Python string formatting for easy interpolation. You can use Bash's string interpolation, but why would you?

MERCURY_V="20.01"
$PATH.insert(0, f"/usr/local/mercury-{MERCURY_V}/bin")
$MANPATH = [f"/usr/local/mercury-{MERCURY_V}/share/man"]
$INFOPATH = [f"/usr/local/mercury-rotd-{MERCURY_V}/share/info"]

Aliases are just a Python dictionary:

aliases.update({
    "python": "python3",
    "gs": "git status",
    "gd":"git diff",
    "gc":"git commit",
    "go":"git checkout",
    "ga":"git add",
    "fd":"fdfind",
    "gpush":"git push",
    "gpull":"git pull",
    "mag":"ag -G .m$",
    "sl":"ls",
})

Easily create functions to be associated with aliases. Here's an alias that takes an argument. It calls into a python function and downloads a file from S3.

def get_project_file_from_aws(lang):
  filename = some_calc(lang)
  aws s3 @(f"s3://my-bucket/path/{filename}") .

aliases["get_project_file"] = get_project_file_from_aws

Then you could call it like so:

$ get_project_file fr

Your RC file is easier to read with Python:

if not $(which rbenv):
  eval "$(rbenv init -)"

You can easily set environment variables from Python code:

def set_login_credentials():
  import requests
  body = {"username": "foo", "password": "bar"}
  json = requests.post("example.com/login", body=body).json()
  $CREDS = json["creds"]

If you combine this with autoxsh you can set these variables when entering a project's directory.

Scripting is much easier in Xonsh, on account of the low barrier to entry. You want a script to review and delete older local branches? Sure we can hack something together:

branches = [b.replace("*", "").strip() for b in $(get branch).split("\n")]
...
branches_to_delete = [ branch
                       for branch, date in branches_to_date.items()
                       if date[0] <= delete_time
]
for branch in branches_to_delete:
  git branch -D @(branch)

At other times there's a certain simplicity in writing everything in Python. It was neat to write this custom autovox policy. What is autovox? It's Xonsh's equivalent for autovenv. What is vox? I'll get into that in the 'bad' section.

# This policy mimics that of the autovenv,
# storing a venv in a single directory
@events.autovox_policy
def autovenv_policy(path, **_):
    from pathlib import Path
    parts = path.parts
    # Handle the root as a special case
    if len(parts) <= 1:
        return None
    venv = Path('~/.local/share/autovenv/venvs').expanduser().joinpath(parts[-1])
    if venv.exists():
        return venv
    # Otherwise use the default vox home
    venv = Path($VIRTUALENV_HOME).joinpath(parts[-1])
    if venv.exists():
        return venv

The Bad

While it's nice to use Python whenever I want, there are some problems.

Command line usage

The first is that Python is not built for command line entry. I find myself skipping forward and backward in the line or splitting the command across multiple lines.

Here's a contrived example:

$ files = [f.strip() for f in $(ls) if len(f) > 4]
$ for file in files:
      mv @(file) @(f"temp-{file}")

This is easy to write, but inconvenient at the prompt.

I want to do something like this:

ls | filter(len(x) > 4) | foreach(f, mv @(f) @(f"temp-{f}"))

This is nice because it's a single line and doesn't require jumping around terminal. You can kinda get there, but it requires adding a lot of text on the left hand side of the line. Both the data & code should flow from the left to the right.

map(lambda f: mv @(f) @(f"temp-{f}"), filter(lambda f: len(f) > 4, $(ls)))

Note in the above example I didn't bother stripping out the newline at the end of each file, but that would be a necessary step in many cases.

Python for loops as the "pythonic" way for map/filter don't work well either.

I want some form of function chaining. I could subclass List to include by helper functions, but at this point the amount of work you're doing defeats the benefit of using a familiar language.

Interpreter Madness

Xonsh runs on Python. OK sure. But the Python version is distinct from the one you might use in your other projects.

So executing python in your shell might be different from executing it in your projects. That's all well and good, but suppose you want to use requests in your shell. You don't pip install requests instead you xpip install requests. This works fine, but it's just another hoop to remember to jump through.

I had a number of problems with Python interpreters. Xonsh would use one interpreter, say Python 3.8, and then some other program would install 3.9 on system. Xonsh would upgrade to 3.9 and all my virtual environments would break.

Vox

That's right, you can't use the normal virtual environment activation script. Instead Xonsh comes with it's own virtual environment command, vox. Besides he breaking mentioned above I didn't have any issues, but say goodbye to source venv/bin/activate -- you have a new command to learn.

Bash Compatibility

Not all bash commands work in Xonsh. In some cases copying a command from elsewhere won't "just work". In most cases string quoting fixed the issue.

Speed

Xonsh is slower than most other shells. Starting a new terminal has a noticeable delay. I suppose this is the price of Python.

If you're trying to speed up your Xonsh config, I found that moving the contents of my ~/.bashrc to ~/.xonshrc saved me a few milliseconds.

Separately, auto-complete panicked when I entered large directories.

Weird IO Bugs

I had particular trouble with commands that launched the editor, such as git and github commands. I added these as exceptions in my xonshrc. So... resolved?

Crashing

Every now and then Xonsh crashes and I need to start a new terminal. This doesn't happen often, but I've never had Bash crash on me.

Summary

I'm not ready to deal with the delays and occasional frustrations of Xonsh, though it may be great for someone with more patience. I'm thankful for Xonsh because it showed me how easy and useful small helper scripts can be. I may keep Xonsh around just for running bash/python scripts in the future, even if I don't use it as my main shell.

social