Build CLI blazingly fast with python-fire 🔥
Tags: python, cli, programming, productivity
Published: 2023-06-05 02:37:07

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

```bash
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](https://github.com/google/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.**

```python
# 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:

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

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

```bash
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
```


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a8qguq0oehpq551xbcge.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:

```python
# 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:

```python
# 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.

```python
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:

```python
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.

```bash
pip install -e .
```

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

```bash
to utc
to utc --pretty
to utc --help
```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5durgucov9ev0pggh7e4.gif)

This is pretty neat!

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

- Deactivate your virtual environment:

    ```bash
    deactivate
    ```

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

    ```bash
    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.

```python
...
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

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

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lrxayr0squz5yxyarmir.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`.

```python
...

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:

```python
to dt utc
to dt leap 2025
```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r1pwnzwivh1fnagrfykz.gif)

Password command will be normal:

```python
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.