paint-brush
How to Build a User-Friendly CLI from Pure Python Functionsby@shakorzayev
287 reads

How to Build a User-Friendly CLI from Pure Python Functions

by Shako RzayevMarch 11th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This article explains how [DynaCLI] makes writing Command Line Interfaces in Python easy and efficient. The basic idea behind DynaCLI is to accelerate and automate the process of building CLI applications as much as possible by focusing solely on Python code. We’ll demonstrate this approach by generating a QR code that indicates a person’s vaccination status. The code is first writing methods to implement the core code, and, second, develop the CLI predefined as a set of arguments.

People Mentioned

Mention Thumbnail
Mention Thumbnail

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - How to Build a User-Friendly CLI from Pure Python Functions
Shako Rzayev HackerNoon profile picture


DynaCLI (Dynamic CLI) is a cloud-friendly, open source library for converting pure Python functions into Linux Shell commands. This article explains how DynaCLI makes writing Command Line Interfaces in Python easy and efficient, using as an example a function to generate a QR code that records a person’s vaccination status.


This is a continuation of the article How to Write User-friendly Command Line Interfaces in Python, which describes how to use different Python libraries like argparse, Click, Typer, docopt, and Fire to build CLI applications. To understand the motivations and use cases for DynaCLI, read the Medium interview. To learn the differences between DynaCLI and alternatives, refer to DynaCLI vs. Alternatives.


Motivation

The basic idea behind DynaCLI is to accelerate and automate the process of building CLI applications as much as possible by focusing solely on Python code. Function arguments are converted to CLI commands, and DynaCLI generates help messages from the Python function docstrings.


We’ll demonstrate this approach by generating a QR code that indicates a person’s vaccination status.


Sounds interesting? Let’s start exploring…


A conventional CLI building process

Building CLIs, in general, is a two-step process: first, write the core code and, second, develop the CLI predefined as a set of arguments, as shown below.


Let’s start by looking at the code from the original, reference article).

In this snippet, they are first writing methods to implement the core code.


from dataclasses import dataclass, field, asdict
import requests
import shutil
from datetime import datetime

class ValidationException(Exception):
    pass

@dataclass
class Vaccination:
    manufacturer: str
    date: str

    def __post_init__(self):
        try:
            date = datetime.strptime(self.date, "%Y-%m-%d")
        except Exception:
            raise ValidationException("Vaccination date should be in format YYYY-MM-DD.")
        if date > datetime.today():
            raise ValidationException("Vaccination date should not be a future date.")
        if self.manufacturer.lower() not in ["pfizer","moderna","astrazeneca","janssen","sinovac"]:
            raise ValidationException("Your vaccine manufacturer is not approved.")

@dataclass
class QRCode:
    name: str
    birth: str
    vaccine: list[Vaccination] = field(default_factory=list)

def generate_qr_code(qr_code):
    code = asdict(qr_code)
    res = requests.get(f"http://api.qrserver.com/v1/create-qr-code/?data={code}", stream=True)
    if res.status_code == 200:
        with open("qr_code.png", "wb") as f:
            res.raw.decode_content = True
            shutil.copyfileobj(res.raw, f)
        return "QR code has been generated."
    else:
        raise Exception("QR code cannot be generated by QR Code Generator API.")


Following that is the code needed to build the CLI. This example uses the argparse library (code snippet from the referenced article):


def main():
    parser = argparse.ArgumentParser(description="Generate your vaccination QR code.")
    parser.add_argument("-n", "--name", type=str, help="Your name", required=True)
    parser.add_argument("-b", "--birth", type=str, help="Your birthday in YYYY-MM-DD format", required=True)
    parser.add_argument("-m", "--manufacturer", type=str, nargs="+", help="The vaccine manufacturer", required=True, choices=[
            "pfizer","moderna","astrazeneca","janssen","sinovac"])
    parser.add_argument("-d", "--date", type=str, nargs="+", help="The date of vaccination", required=True)
    args = parser.parse_args()
    
    if len(args.manufacturer) != len(args.date):
        logging.error(
            "The number of vaccine manufacturer doesn't match with the number of vaccine dates."
        )
        exit(1)
    
    qr_code = QRCode(name=args.name, birth=args.birth, vaccine=[Vaccination(args.manufacturer[i], args.date[i]) for i in range(len(args.date))])
    generate_qr_code(qr_code)

if __name__ == "__main__":
    try:
        main()
    except ValidationException as e:
        logging.error(e)
    except Exception as e:
        logging.exception(e)


CLI Designing with DynaCLI

With DynaCLI, we can skip the second part by designing our functions to be CLI-friendly. Functionally, the core logic is the same. To demonstrate the differences, we will update the original code as shown below.


Before that, just quickly install DynaCLI to get ready:
pip3 install dynacli


Restructuring

First of all, we would like to restructure the code. Thinking about the CLI design, there should be ./qr-code then green-badge feature-set (the actual Python package), which is for storing all commands, followed by generate to output the actual QR codes:


$ tree green_badge -I __pycache__
green_badge
├── generate.py
└── __init__.py


From the original code, we know that the vaccine manufacturers are a limited set of companies; this kind of information is a good fit for type Enum.


from enum import Enum

class Manufacturer(Enum):
    pfizer = 1
    moderna = 2
    astrazeneca = 3
    janssen = 4
    sinovac = 5


Alternatively, we can use Literal. I am going to use Enum here instead of Literal. By choosing this approach, we add some defensive control on input data as Enum will automatically validate the data without any redundant custom validator.


Therefore, we do not need a manufacturer validation inside __post_init__ anymore and can remove it. Going further, the whole purpose of @dataclass here is validation, so we can replace this with TypedDict.


Our updated code generate.py file looks like this:


class Vaccination(TypedDict):
    date: date
    manufacturer: Manufacturer


class QRCode(TypedDict):
    name: str
    birth: str
    vaccinations: list[Vaccination]


CLI Building

The second big step is to design the actual generate function in a way that it is going to accept all the necessary information as arguments:


def generate(first_name: str, last_name: str, birth_date: str,  **vaccinations: Manufacturer) -> None:
    """
    Generate your vaccination QR code.

    Args:
        first_name (str): name of the vaccinated person
        last_name (str): surname of the vaccindate person
        birth_date (str): birthday of the vaccinated person in YYYY-MM-DD format
        **vaccinations (Manufacturer): vaccination information as date=manufacturer 

    Return: None
    """
    # TODO: handle duplicate or wrong dates.
    qr_code = QRCode(
        name=f"{first_name} {last_name}",
        birth=birth_date,
        vaccinations=[
            Vaccination(manufacturer=manufacturer, date=datetime.strptime(date, "%Y-%m-%d"))
            for date, manufacturer in vaccinations.items()
        ],
    )
    res = requests.get(
        f"http://api.qrserver.com/v1/create-qr-code/?data={qr_code}", stream=True
    )
    if res.status_code != 200:
        raise Exception("QR code cannot be generated by QR Code Generator API.")
    with open("qr_code.png", "wb") as f:
        res.raw.decode_content = True
        shutil.copyfileobj(res.raw, f)
    print("QR code has been generated.")


Ideally, it should print a nice error message if one of the vaccination pairs contains wrong or duplicate date — but for the sake of simplicity, we just skip this step.


The major change is that we are going to accept the date and the manufacturer name as key-value pairs. This is quite intuitive, isn’t it?


The CLI call will look something like this:
./qr-code green-badge generate John Doe 1989-10-24 2021-01-01=pfizer 2021-06-01=pfizer


The next important difference is that DynaCLI populates help messages from the docstrings in the methods containing the explanation of the arguments. …You are right, this is quite Pythonic; if the explanations are already added once, why not use the same information to build the help messages in the CLI.


The rest of the code is the same — functionally the code logic is unchanged.


CLI Entrypoint

Now as the last step, we create the CLI entry point with DynaCLI.

We have already provided the bootstrapper script, all you need is to provide the path for dynacli command:


$ dynacli init qr-code path=.
Successfully created CLI entrypoint qr-code at /home/ssm-user/OSS/medium-articles/how_to_convert_python_functions/code


It will fill the qr-code script with some boilerplate, starter code, you need just change commented portions, in order to have a customized version of your CLI:


#!/usr/bin/env python3

"""
DynaCLI bootstrap script # Change me
"""


import os
import sys
from typing import Final

from dynacli import main

cwd = os.path.dirname(os.path.realpath(__file__))

__version__: Final[str] = "0.0.0" # Change me to define your own version


search_path = [cwd] # Change me if you have different path; you can add multiple search pathes
sys.path.extend(search_path)
# root_packages = ['cli.dev', 'cli.admin'] # Change me if you have predefined root package name
# main(search_path, root_packages) # Uncomment if you have root_packages defined

main(search_path)


Change the commented sections, remove redundant comments and you are ready to go:


#!/usr/bin/env python3

"""
Sample QR Generator
"""

import os
import sys

from dynacli import main

cwd = os.path.dirname(os.path.realpath(__file__))

__version__ = '1.0'


search_path = [cwd]
sys.path.extend(search_path)

main(search_path)


As you must have already noticed, there is no CLI pre-processing, adding arguments, version callback, etc. Everything is dead simple Python. DynaCLI grabs the version from version and the CLI name from the `qr_code` docstring.


That’s it — the changes are done and we are ready to run the CLI.

Getting help output:


$ ./qr-code -h

usage: qr-code [-h] [-v] {green-badge} ...

Sample QR Generator

positional arguments:
  {green-badge}
    green-badge  Generate Green Badge

optional arguments:
  -h, --help     show this help message and exit
  -v, --version  show program's version number and exit


The green-badge help message comes from the package __init__.py file:


$ cat green_badge/__init__.py 
"""
Generate Green Badge
"""
__version__ = "2.0"


Getting the version of the CLI itself:


$ ./qr-code --version
qr-code - v1.0


Now, let’s think about a different situation when you are porting an already developed package to be exposed by the CLI, and it has its own version set to v2.0. Should it mess with the CLI version? It is not ideal to have a single version for the package and the CLI.


With DynaCLI, you can version your packages and even modules.

Again, it is quite Pythonic: just add __version__ to package __init__.py and to the generate.py module itself.


$ ./qr-code green-badge --version
qr-code green-badge - v2.0
$ ./qr-code green-badge generate --version
qr-code green-badge generate - v3.0


DynaCLI detects generate.py and the generate function in it. Only public names are exposed by the CLI and the function docstring is used to register the help message.


Here, we would like to stress that, with DynaCLI, there is no need to begin writing things from scratch and redo the whole code. All you need is to import already existing functionality to an intermediate representation as we did ingenerate.py and register it in the CLI.


This effectively conforms to the Open/Closed Principle, where your original code is closed to modification but is open to being extended via CLI.


Our final version of generate.py looks like:


import shutil
import requests

from datetime import date, datetime
from typing import Literal, TypedDict
from enum import Enum


__version__ = "3.0"


class Manufacturer(Enum):
    pfizer = 1
    moderna = 2
    astrazeneca = 3
    janssen = 4
    sinovac = 5


class Vaccination(TypedDict):
    date: date
    manufacturer: Manufacturer


class QRCode(TypedDict):
    name: str
    birth: str
    vaccinations: list[Vaccination]


def generate(first_name: str, last_name: str, birth_date: str,  **vaccinations: Manufacturer) -> None:
    """
    Generate your vaccination QR code.

    Args:
        first_name (str): name of the vaccinated person
        last_name (str): surname of the vaccindate person
        birth_date (str): birthday of the vaccinated person in YYYY-MM-DD format
        **vaccinations (Manufacturer): vaccination information as date=manufacturer 

    Return: None
    """
    # TODO: handle duplicate or wrong dates.
    qr_code = QRCode(
        name=f"{first_name} {last_name}",
        birth=birth_date,
        vaccinations=[
            Vaccination(manufacturer=manufacturer, date=datetime.strptime(date, "%Y-%m-%d"))
            for date, manufacturer in vaccinations.items()
        ],
    )
    res = requests.get(
        f"http://api.qrserver.com/v1/create-qr-code/?data={qr_code}", stream=True
    )
    if res.status_code != 200:
        raise Exception("QR code cannot be generated by QR Code Generator API.")
    with open("qr_code.png", "wb") as f:
        res.raw.decode_content = True
        shutil.copyfileobj(res.raw, f)
    print("QR code has been generated.")


You can get help about the command(it is our Python function in fact) using:


$ ./qr-code green-badge generate -h

usage: qr-code green-badge generate [-h] [-v] first_name last_name birth_date [vaccinations <name>=<value> ...]

positional arguments:
  first_name            name of the vaccinated person
  last_name             surname of the vaccindate person
  birth_date            birthday of the vaccinated person in YYYY-MM-DD format
  vaccinations <name>=<value>
                        vaccination information as date=manufacturer ['pfizer', 'moderna', 'astrazeneca', 'janssen', 'sinovac']

optional arguments:
  -h, --help            show this help message and exit
  -v, --version         show program's version number and exit


In summary, the highlights of using DynaCLI in this sample included:


  • We did not add or register any help messages — they were grabbed from the function docstrings.


  • DynaCLI detects **kwargs and registers them as <name>=<value> pair.


  • No special CLI pre-processing, adding arguments, or version callbacks were required.


Now, it is time to run the command and generate a QR code with the arguments provided:


$ ./qr-code green-badge generate Shako Rzayev 1989-10-24 2021-01-01=pfizer 2021-06-01=pfizer

QR code has been generated.


That’s it. We have focused only on the core functionality, simplified basic features of a CLI application, and reshaped it to be more user-friendly. That’s how CLIs should be!


The source code: how_to_convert_python_functions/code


DynaCLI is an open source offering from BST LABS. Our goal is to make it easier for organizations to realize the full potential of cloud computing through a range of open source and commercial offerings. We are best known for CAIOS, the Cloud AI Operating System, a development platform featuring Infrastructure-from-Code technology. BST LABS is a software engineering unit of BlackSwan Technologies.