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