Yankee Maharjan
Yankee Maharjan

Yankee Maharjan

Build CLI blazingly fast with python-fire 🔥

Build CLI blazingly fast with python-fire 🔥

Yankee Maharjan
·Oct 26, 2022·

6 min read

Subscribe to my newsletter and never miss my upcoming articles

Command line applications are developers best friend. Want to get something done quickly? Just a few keystrokes and you already have what you are looking for.

Python is the first language many developers pick if they need to hack together something quickly. But what we scrap together is not a CLI in its entirety most of the time, you need to manage flags, parse the arguments, chain sub-commands, and many more which is a hassle, thus results in multiple small and unmanaged scripts.

In todays article we are going to put an end to this and see how we can build reasonably feature rich CLI in mere minutes without any fancy decorators or anything.

Create and activate a virtual environment

python -m venv venv

source venv/bin/activate

# Install python-fire 🔥
pip install fire

Your first sub-command

Our CLI application is going to be an aggregation of bunch of tools, so we will just call it tools CLI.

With python-fire you can use either function or class to create your subcommands. But I find working with classes more intuitive and manageable. Our first command is going to be a sub-command that shows us the UTC time.

We will create a new method utc() which will be our sub-command and we have an argument called pretty which will be the flag for our sub-command which prints UTC date time in more human readable format. This argument already has a default value so this is not a required flag.

# tools.py

from datetime import datetime
import fire

class Tools:
    def utc(self, pretty: bool = False):
        """
        Get UTC date time
        """
        utc_time = datetime.utcnow()

        if pretty:
            ## strftime format codes:
            # https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
            print(utc_time.strftime("%B %d :: %H:%M %p"))
        else:
            print(utc_time)

Next we need to run this file as a script so at the end of our file we need to add the following:

if __name__ == "__main__":
    fire.Fire(Tools)

Now we have our CLI ready! Let’s run it!!

python tools.py utc
python tools.py utc --pretty

# For help message
python tools.py

# For sub-command help message
python tools.py utc --help

Basic.gif

It’s a bit overwhelming to type through all of these overheads python tools.py <command>; well it’s not even like a CLI I was hoping for 🤷‍♂️. You might have questions like:

  • How can I invoke it from any location I want?
  • I want to name it to something I find intuitive, how do I do that?

Well for that you would want to create a distribution for it.

Package for Command Line 📦

First we need to revamp our program a little to accommodate packaging:

# REMOVE THIS CHUNK
if __name__ == "__main__":
    fire.Fire(Tools)

# ADD THIS CHUNK
def run():
    fire.Fire(Tools)

Now let’s create a setup.py file to manage our packaging/distribution. You can use this file as a reference to create your own CLI:

# setup.py
"""Package setup"""
import setuptools

# Development Requirements
requirements_dev = ["pytest", "black", "mypy", "flake8", "isort"]

setuptools.setup(
    name="tools_cli",
    version="0.0.1",
    author="Yankee Maharjan",
    url="https://yankee.dev/build-cli-blazingly-fast-with-python-fire",
    description="Collection of handy tools using CLI",
    license="MIT",
    packages=setuptools.find_packages(exclude=["dist", "build", "*.egg-info", "tests"]),
    install_requires=["fire"],
    extras_require={"dev": requirements_dev},
    entry_points={"console_scripts": ["to = tools:run"]},
)

The line you need to focus here is the entry_points, which describes the entry point to our program as a console script.

entry_points={"console_scripts": ["to = tools:run"]},

Here to is the name of our CLI, you can name it to anything you like. If you want to name it to brr it will go like this:

entry_points={"console_scripts": ["brr = tools:run"]},

tools:run represent the name of our module followed by the function it needs to run. Console Scripts always require a function to run hence the modification we did earlier.

Feels like a CLI 💆‍♂️

Now let’s install our CLI in an editable mode within our virtual environment. This is like hot reloading for your CLI, whatever changes you make is reflected instantly.

Inside your project directory run the following command.

pip install -e .

Now you can use your CLI using the command to or whatever you put on the console_scripts

to utc
to utc --pretty
to utc --help

desired_command.gif

This is pretty neat!

Now how do I make sure I can run it from any location I want?

  • Deactivate your virtual environment:

      deactivate
    
  • Install the project in editable mode again on your global site-packages:

      pip install -e .
    

Now this is done, you will have your CLI accessible throughout the system. But note that if you make any changes to the main CLI logic, it will be reflected instantly.

Bonus: Nested Commands ➿

If you have made it this far, then you are set to make your own CLI and get done with most of the use cases. But if you want to see some more then stick around for a bit.

Let’s add other commands to our tool, first a sub-command called leap() that validates if the given year is leap or not, lastly a sub-command called pw() to generate a strong password.

...
import calendar
import string
import secrets

class Tools:
    def utc(self, ...):
        ...

    def leap(self, year:int): # required: since no default value here
        """
        Check if given year is leap or not
        """
        print(calendar.isleap(year))

    def pw(self, len: int = 16):
        """
        Generate strong password
        """
        alphabet = string.ascii_letters + string.digits + string.punctuation
        pwd_length = len

        pwd = ""
        for i in range(pwd_length):
            pwd += "".join(secrets.choice(alphabet))

        print(pwd)

def run():
    fire.Fire(Tools)

Now run the commands

to leap 2022
to pw
to pw --len 22
to pw 25

leap__pw.gif

Sometimes there are related commands that you want to group together, like in our case we can group utc and leap under something like datetime or dt for short. Basically what we want to do here is nested commands.

Let’s group our commands. We will move our leap() and utc() method inside of a new class called DateTime.

...

class DateTime:
    def utc(self, pretty: bool = False):
        """
        Get UTC time
        """

        from datetime import datetime

        utc_time = datetime.utcnow()

        if pretty:
            print(utc_time.strftime("%B %d :: %H:%M %p"))
        else:
            print(utc_time)

    def leap(self, year:int): # required: since no default value here
        """
        Check if given year is leap or not
        """
        print(calendar.isleap(year))

class Tools:
    def __init__(self):
        self.dt = DateTime()

    def pw(self, ...):
        ...

Sub-command is determined by whatever we put as variable name when instantiating the new DateTime class. Here we have named it as dt, but you can name it as datetime, dtt or whatever you want.

Now, we have more organized sub-commands for our CLI. If you want to run commands that are related to date time you can do so using to dt <command-name> ; for example:

to dt utc
to dt leap 2025

group_commands.gif

Password command will be normal:

to pw
to pw --len 30

Conclusion

Using python-fire makes the process of creating CLIs really easy and intuitive because you are using nothing but Python functions and classes. I hope this mini rundown of the tool and how to package it for your daily use has been helpful.