Python CLIs: Finding a Pattern

Lately, I’ve been building Python CLI applications. Small things. They solve direct, niche, personal problems. A personal CRM to manage people-to-digital-asset relationships because the corporate org chart doesn’t cut it. A CVE data explorer because it’s painful to query multiples websites and organizations. A personal GitHub inventory to manage multiple organizations, standardized repo configs, and filling GitHub’s UI gaps. And soon, a CLI app for browsing archived Davinci Resolve projects and curating a personal B-roll and assets library.

Distinct problems, but I’ve found a pattern for the code that is helping me write solutions for these problems. Sharing it here. This is my structure. There are many like it, but this one is mine…

It’s likely to change.


Project structure

It roughly flows something like this:

- docs
- src
  - project_cli
    - actions
    - models
    - storage
    - utilities
    main.py
- tests
README.md

Main Directories

src/project_cli

All source code lives within src/projectcli (where “project_cli” takes the name of the individual project). main.py in this directory is the main entry point for the whole application.

import argparse

def main():
    print("Python template - main()")

    # Setup parsers
    parser = argparse.ArgumentParser(description="Template CLI")
    subparsers = parser.add_subparsers(dest="command")
    list_parser = subparsers.add_parser("list", help="List data")

    # Parse user input
    args = parser.parse_args()

    if args.command == "list":
        print("list action goes here")

if __name__ == "__main__":
    main()

Actions

A holding place for wrappers around argument parsing code. I’m still using argparse to build my CLI apps. I generally try to keep literal argument parsing in the main.py file, and all “setup and handling” code that those arguments are orchestrating is handled in the actions.

from yaspin import yaspin
from yaspin.spinners import Spinners

def handle_list(is_tty: bool = True) -> None:
  # Configure spinner based on TTY detection
  if is_tty:
      spinner_config = {"color": "green"}
  else:
      # No color configuration to avoid warnings
      spinner_config = {}
        
  print('handle list command here')

Models

Any classes and data models necessary for reasoning about the data. This is purposefully left open-ended. In my personal projects, I find full blown object-oriented design is often too formalized. However, a few classes and some basic encapsulation often helps me reason about the most spartan projects.

from rich.console import Console
from rich.table import Table

class SomeClass:
  some_id: str
  # Rest of properties here

  def __init__(self, some_id: str = ""):
    """Constructor"""
    self.some_id = some_id
    # NOTE: Rest of constructuor assignments here
  
  def display(self):
    """Generic display method to show class instance
      information"""
    
    # TODO: Implement display information

Storage

All code related to direct interaction with the underlying database. Whether you’re using SQLite, Postgres, Mongo, etc. Place all of that here.

"""Storage Module
  My pattern for the storage functionalities is 
  the biggest area for improvement/change in my
  pattern. For now, this is a naive attempt to 
  keep all database functionality in a single 
  file.
"""

_SCHEMA_SQL = """
# TODO: SQL to create tables
"""

def init_cve_db(db_path: str | Path, *, timeout: float = 5.0) -> sqlite3.Connection:
    """Create (if needed) and return a connection to the SQLite database."""
    # TODO: to be implemented
   
def upsert_record():
    """Handle a record to be upsert-ed"""
    # TODO: to be implemented

Utilities

Anything that either: a) generic enough to not have a clear place for it, or b) core functionality repeated in enough places it can be considered a “common utility” for the application. Often this is specific file downloading, bespoke file parsing, directory operations, text shortening, etc.

import shutil
import os
import json
import zipfile
from pathlib import Path
from typing import Any

def read_json_file(file_path: Path) -> Any:
    """Read and return contents of JSON file"""
    # TODO: implement function

def temp_dir_exists(path: Path = Path("./tmp")) -> bool:
    """Check if temp directory exists"""
    # TODO: implement function

def clean_directory(dir_to_be_deleted: str) -> None:
    """Remove all contents from a directory"""
    # TODO: implement function

def remove_file(file_name_for_delete: str) -> None:
    """Safely delete a file"""
    # TODO: implement function

def safe_extract(zip_path: str | Path, dest_dir: str | Path) -> Path:
    """Safe extract of .zip file"""
    # TODO: implement function

Pulling it all together

I’m slowly building this architecture into a template repo: https://github.com/meddlin/python-template