I frequently find myself writing my own short command-line scripts in Python that help me with day-to-day tasks.
It’s so easy to throw together a single-file Python command-line script and throw it in my ~/bin
directory!
Well… it’s easy, unless the script requires anything outside of the Python standard library.
Recently I’ve started using uv and my primary for use for it has been fixing Python’s “just manage the dependencies automatically” problem.
I’ll share how I’ve been using uv… first first let’s look at the problem.
A script without dependencies
If I have a Python script that I want to be easily usable from anywhere on my system, I typically follow these steps:
- Add an appropriate shebang line above the first line in the file (e.g.
#!/usr/bin/env python3
) - Set an executable bit on the file (
chmod a+x my_script.py
) - Place the script in a directory that’s in my shell’s
PATH
variable (e.g.cp my_script.py ~/bin/my_script
)
For example, here’s a script I use to print out 80 zeroes (or a specific number of zeroes) to check whether my terminal’s font size is large enough when I’m teaching:
1 2 3 4 5 6 |
|
This file lives at /home/trey/bin/0
so I can run the command 0
from my system prompt to see 80 0
characters printed in my terminal.
This works great! But this script doesn’t have any dependencies.
The problem: a script with dependencies
Here’s a Python script that normalizes the audio of a given video file and writes a new audio-normalized version of the video to a new file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
This script depends on the ffmpeg-normalize Python package and the ffmpeg utility.
I already have ffmpeg
installed, but I prefer not to globally install Python packages.
I install all Python packages within virtual environments and I install global Python scripts using pipx.
At this point I could choose to either:
- Create a virtual environment, install
ffmpeg-normalize
in it, and put a shebang line referencing that virtual environment’s Python binary at the top of my script file - Turn my script into a
pip
-installable Python package with apyproject.toml
that listsffmpeg-normalize
as a dependency and usepipx
to install it
That first solution requires me to keep track of virtual environments that exist for specific scripts to work. That sounds painful.
The second solution involves making a Python package and then upgrading that Python package whenever I need to make a change to this script. That’s definitely going to be painful.
The solution: let uv handle it
A few months ago, my friend Jeff Triplett showed me that uv
can work within a shebang line and can read a special comment at the top of a Python file that tells uv which Python version to run a script with and which dependencies it needs.
Here’s a shebang line that would work for the above script:
1 2 3 4 5 6 7 |
|
That tells uv that this script should be run on Python 3.12 and that it depends on the ffmpeg-normalize
package.
Neat… but what does that do?
Well, the first time this script is run, uv will create a virtual environment for it, install ffmpeg-normalize
into that venv, and then run the script:
1 2 3 4 5 |
|
Every time the script is run after that, uv finds and reuses the same virtual environment:
1 2 3 4 |
|
Each time uv runs the script, it quickly checks that all listed dependencies are properly installed with their correct versions.
Another script I use this for is caption, which uses whisper (via the Open AI API) to quickly caption my screencasts just after I record and edit them. The caption quality very rarely need more than a very minor edit or two (for my personal accent of English at least) even for technical like “dunder method” and via the API the captions generate very quickly.
See the inline script metadata page of the Python packaging users guide for more details on that format that uv is using (honestly I always just copy-paste an example myself).
uv everywhere?
I haven’t yet fully embraced uv everywhere.
I don’t manage my Python projects with uv, though I do use it to create new virtual environments (with --seed
to ensure the pip
command is available) as a virtualenvwrapper replacement, along with direnv.
I have also started using uv tool as a pipx replacement and I’ve considered replacing pyenv with uv.
uv instead of pipx
When I want to install a command-line tool that happens to be Python powered, I used to do this:
1
|
|
Now I do this instead:
1
|
|
Either way, I end up with a countdown
script in my PATH
that automatically uses its own separate virtual environment for its dependencies:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
uv instead of pyenv
For years, I’ve used pyenv to manage multiple versions of Python on my machine.
1
|
|
Now I could do this:
1
|
|
Or I could make a ~/.config/uv/uv.toml
file containing this:
1
|
|
And then run the same thing without the --preview
flag:
1
|
|
This puts a python3.10
binary in my ~/.local/bin directory
, which is on my PATH
.
Why “preview”?
Well, without it uv doesn’t (yet) place python3.13
in my PATH
by default, as this feature is currently in testing/development.
Self-installing Python scripts are the big win
I still prefer pyenv for its ability to install custom Python builds and I don’t have a preference between uv tool
and pipx
.
The biggest win that I’ve experienced from uv so far is the ability to run an executable script and have any necessary dependencies install automagically.
This doesn’t mean that I never make Python package out of my Python scripts anymore… but I do so much more rarely. I used to create a Python package out of a script as soon as it required third-party dependencies. Now my “do I really need to turn this into a proper package” bar is set much higher.