Sunday, 31 August 2025

Creating Your Own Command-Line Tools with Python

Turn tiny scripts into polished CLIs you can install, share, and run from anywhere.

Contents
  1. Why build a CLI?
  2. Prerequisites
  3. A 3-minute CLI with argparse
  4. Recommended project structure
  5. A real app with Typer (Click) + Rich
  6. Package & install with entry points
  7. Testing CLIs
  8. Distribute with pipx & PyPI
  9. Polish: logging, config, shell completion
  10. Common gotchas

1) Why build a CLI?

  • Speed: One command beats 10 clicks.
  • Automation: Scriptable in cron, CI, or other tools.
  • Shareable: Pack once, install anywhere.
  • Composability: Works well with pipes (|) and redirects.

2) Prerequisites

  • Python 3.9+ recommended
  • pip (and optionally pipx)
  • Basic terminal familiarity
Tip: Use a virtual environment (python -m venv .venv then source .venv/bin/activate or .venv\Scripts\activate on Windows) while developing.

3) A 3-minute CLI with argparse

Start simple with the standard library:

# greet/__main__.py
import argparse

def main():
    parser = argparse.ArgumentParser(prog="greet", description="Say hello from your CLI.")
    parser.add_argument("name", help="Who to greet")
    parser.add_argument("--shout", action="store_true", help="Uppercase the greeting")
    args = parser.parse_args()

    msg = f"Hello, {args.name}!"
    if args.shout:
        msg = msg.upper()
    print(msg)

if __name__ == "__main__":
    main()

Run it straight from the module:

python -m greet Remo
python -m greet Remo --shout

Next, we’ll package it so you can just type greet anywhere.

4) Recommended project structure

greet/
├─ greet/
│  ├─ __init__.py
│  └─ __main__.py
├─ pyproject.toml
└─ README.md

5) A real app with Typer (Click) + Rich

Typer (built on Click) gives you subcommands, type hints, smart help, and shell completion with almost no boilerplate. We’ll build a tiny to-do manager called tasker that stores data in ~/.tasker.json and prints pretty tables using Rich.

# tasker_cli/__main__.py
from __future__ import annotations
import json, os
from pathlib import Path
from typing import List, Dict
import typer
from rich.console import Console
from rich.table import Table

app = typer.Typer(add_completion=True)
console = Console()
DATA_FILE = Path(os.getenv("TASKER_FILE", Path.home() / ".tasker.json"))

def load_tasks() -> List[Dict]:
    if DATA_FILE.exists():
        return json.loads(DATA_FILE.read_text())
    return []

def save_tasks(tasks: List[Dict]) -> None:
    DATA_FILE.write_text(json.dumps(tasks, indent=2))

@app.command()
def add(text: str = typer.Argument(..., help="Task description")):
    """Add a new task."""
    tasks = load_tasks()
    next_id = (max([t["id"] for t in tasks]) + 1) if tasks else 1
    tasks.append({"id": next_id, "text": text, "done": False})
    save_tasks(tasks)
    console.print(f"✅ Added task [bold]{text}[/] (id={next_id})")

@app.command("list")
def list_tasks(show_done: bool = typer.Option(True, help="Include completed tasks")):
    """List tasks."""
    tasks = load_tasks()
    table = Table(title="Tasker")
    table.add_column("ID", justify="right")
    table.add_column("Status")
    table.add_column("Task")

    for t in tasks:
        if not show_done and t["done"]:
            continue
        status = "✅" if t["done"] else "•"
        table.add_row(str(t["id"]), status, t["text"])
    console.print(table)

@app.command()
def done(task_id: int = typer.Argument(..., help="Task ID to mark as done")):
    """Mark a task as done."""
    tasks = load_tasks()
    for t in tasks:
        if t["id"] == task_id:
            t["done"] = True
            save_tasks(tasks)
            console.print(f"๐ŸŽ‰ Marked task {task_id} as done")
            return
    raise typer.Exit(code=1)

@app.command()
def clear(confirm: bool = typer.Option(False, "--yes", help="Confirm clearing all tasks")):
    """Clear all tasks."""
    if not confirm:
        console.print("Pass --yes to confirm clearing all tasks."); raise typer.Exit(1)
    save_tasks([]); console.print("๐Ÿงน Cleared tasks.")

def app_main():
    app()

if __name__ == "__main__":
    app_main()

Try it locally:

python -m pip install typer[all] rich
python -m tasker_cli add "Buy milk"
python -m tasker_cli list
python -m tasker_cli done 1
python -m tasker_cli list --no-show-done

6) Package & install with entry points

Use a modern pyproject.toml with PEP 621 metadata and a console script entry. We’ll use hatchling as a lightweight build backend:

# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "tasker-cli"
version = "0.1.0"
description = "Tiny to-do manager CLI built with Typer and Rich"
readme = "README.md"
requires-python = ">=3.9"
authors = [{ name = "Your Name" }]
dependencies = ["typer>=0.9", "rich>=13.0"]

[project.scripts]
tasker = "tasker_cli.__main__:app_main"

Install in editable mode while developing:

python -m pip install -e .
# Now just type:
tasker add "Write blog post"
tasker list
How it works: [project.scripts] creates a platform-specific launcher named tasker that calls tasker_cli.__main__:app_main. No manual shebangs or PATH hacks needed.

7) Testing CLIs

Use pytest plus Typer’s test runner to simulate commands and capture output:

# tests/test_tasker.py
from pathlib import Path
from typer.testing import CliRunner
import os
from tasker_cli.__main__ import app

runner = CliRunner()

def test_add_and_list(tmp_path: Path, monkeypatch):
    monkeypatch.setenv("TASKER_FILE", str(tmp_path / "tasks.json"))
    r = runner.invoke(app, ["add", "Buy milk"])
    assert r.exit_code == 0
    r2 = runner.invoke(app, ["list", "--no-show-done"])
    assert "Buy milk" in r2.stdout
python -m pip install pytest
pytest -q

8) Distribute with pipx & PyPI

Install & run globally with pipx

python -m pip install pipx
pipx install .
# or from a git URL:
pipx install git+https://github.com/you/tasker-cli

Publish to PyPI

python -m pip install build twine
python -m build                # creates dist/*.tar.gz and *.whl
python -m twine upload dist/*  # needs PyPI credentials

After publishing, users can install with:

pipx install tasker-cli
# or:
python -m pip install --user tasker-cli

9) Polish: logging, config, shell completion

Logging

import logging, os
level = os.getenv("TASKER_LOG", "WARNING").upper()
logging.basicConfig(level=getattr(logging, level, logging.WARNING))
logging.getLogger("tasker").info("starting up…")

Configuration

Prefer environment variables (12-factor style). For simple files, read from ~/.config/tasker/config.toml on Linux/macOS or %APPDATA%\tasker\config.toml on Windows.

Shell completion

Typer/Click can generate completions:

# Show completion script:
tasker --show-completion

# Install for your shell (Typer adds helpers):
tasker --install-completion

10) Common gotchas

  • Windows paths: Use pathlib.Path, not hardcoded separators.
  • Unicode: Always read/write text files with UTF-8 (Path(...).read_text()/write_text()).
  • State location: Default to a user-writable path (~), not the current directory.
  • Long-running tasks: Show progress or logs; don’t print nothing for minutes.
  • Exit codes: Return non-zero on failure (raise typer.Exit(1)) so scripts can react.

Wrap-up

You’ve seen the full path from a tiny argparse script to a polished, installable CLI with Typer/Click, Rich tables, tests, and packaging. Start small, wire in entry points, then add ergonomics as you go. Happy hacking!

Automating Daily Video Tasks with FFmpeg: A Beginner’s Guide

FFmpeg is a single command-line tool that can convert, compress, trim, merge, subtitle, watermark, and even batch-process videos. This guide gives you copy-pasteable recipes and quick explanations so you can automate your day-to-day work fast.

What is FFmpeg?

FFmpeg is a free, open-source toolkit that works with nearly any audio/video format. If you handle videos—even casually—FFmpeg lets you automate those repetitive chores into single commands or small scripts.

Install & Verify

macOS: brew install ffmpeg (with Homebrew).
Ubuntu/Debian: sudo apt update && sudo apt install ffmpeg.
Windows: Use a prebuilt zip (add its bin folder to PATH).

ffmpeg -version
ffprobe -v error -show_format -show_streams -i input.mp4

Tip: Use ffprobe to inspect codecs, durations, and bitrates before deciding your automation strategy.

Make Web-Ready MP4 (“Fast Start”)

ffmpeg -i input.mov -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k \
-movflags +faststart -pix_fmt yuv420p output.mp4

Compress/Resize for Sharing

ffmpeg -i input.mp4 -vf "scale=-2:1080" -c:v libx264 -crf 24 -preset slow \
-c:a aac -b:a 128k -movflags +faststart -pix_fmt yuv420p output_1080p.mp4

Trim & Clip (Frame-Accurate)

ffmpeg -ss 00:00:05 -to 00:00:12 -i input.mp4 -c:v libx264 -crf 20 -preset medium \
-c:a aac -b:a 128k clip.mp4

Merge/Concatenate Clips

file 'part1.mp4'
file 'part2.mp4'
file 'part3.mp4'
ffmpeg -f concat -safe 0 -i list.txt -c copy merged.mp4

Extract or Replace Audio

ffmpeg -i input.mp4 -vn -c:a libmp3lame -q:a 2 audio.mp3

Thumbnails & GIFs

ffmpeg -ss 00:00:10 -i input.mp4 -vframes 1 -q:v 2 thumb.jpg

Watermarks & Text

ffmpeg -i input.mp4 -i logo.png -filter_complex "overlay=10:10" -c:a copy marked.mp4

Add Subtitles

ffmpeg -i input.mp4 -i subs.srt -c:v copy -c:a copy -c:s mov_text video_with_subs.mp4

Speed Up / Slow Down

ffmpeg -i input.mp4 -filter_complex "setpts=0.5*PTS;atempo=2.0" fast.mp4

Batch Processing

#!/usr/bin/env bash
for f in *.mov; do
  base="${f%.*}"
  ffmpeg -y -i "$f" -vf "scale=-2:1080" -c:v libx264 -crf 23 -preset medium \
  -c:a aac -b:a 128k -movflags +faststart -pix_fmt yuv420p "${base}.mp4"
done

Cheat-Sheet

TaskCommand
Convertffmpeg -i in.mov -c:v libx264 -c:a aac out.mp4
Compress-vf "scale=-2:1080" -crf 24 -preset slow
Trim-ss 00:00:05 -to 00:00:12 -c:v libx264
Extract Audio-vn -c:a libmp3lame -q:a 2 out.mp3

Top 5 Hidden sed Tricks You Didn’t Know Existed

CLI text-processing one-liners

Everyone knows sed for quick find-and-replace, but it can also splice files together in memory, run shell commands, sample lines, and tee matches out to another file. Here are five surprisingly useful tricks—with copy-ready snippets and portability notes for GNU vs. BSD/macOS.

Table of Contents

  1. Run shell commands inside replacements (e flag)
  2. Select every nth line with step addressing
  3. De-duplicate consecutive lines (no uniq needed)
  4. Write matches to a separate file with w
  5. Multi-line editing by “slurping” lines
  6. Cheat sheet

1) Run shell commands inside replacements (e flag)

Scope: GNU sed (Linux, Homebrew gsed on macOS). Executes the right-hand side as a shell command and substitutes its output. Powerful—handle with care.

Use-case: Fill placeholders in a template with dynamic values (date, git hash, hostname, etc.).

# template.txt:
# Build {{DATE}} • Commit {{GIT}} • Host {{HOST}}

sed -E \
  -e 's/\{\{DATE\}\}/date +%F/e' \
  -e 's/\{\{GIT\}\}/git rev-parse --short HEAD/e' \
  -e 's/\{\{HOST\}\}/hostname/e' \
  template.txt
macOS/BSD alternative (no e flag)
# Use the shell to expand variables, then plain sed:
DATE=$(date +%F) GIT=$(git rev-parse --short HEAD) HOST=$(hostname) \
  sed -E "s/\{\{DATE\}\}/$DATE/; s/\{\{GIT\}\}/$GIT/; s/\{\{HOST\}\}/$HOST/" template.txt

2) Select every nth line with step addressing

Use-case: Sample a gigantic log: print every 3rd line (or 10th, etc.).

# Every 3rd line (GNU sed):
sed -n '1~3p' big.log

# Every 10th line:
sed -n '1~10p' big.log
POSIX/BSD sed workaround
# Print 1st, 4th, 7th, ... line (no ~ support):
sed -n '1p; n; n; :a; n; n; n; p; ba' big.log

3) De-duplicate consecutive lines (no uniq needed)

Use-case: Collapse runs of identical adjacent lines to a single line (e.g., noisy logs).

# Input:
# A
# A
# B
# B
# B
# C

sed '$!N; /^\(.*\)\n\1$/!P; D' file.txt
# Output:
# A
# B
# C

4) Write matches to a separate file with w

Use-case: Extract error messages to errors.txt while leaving stdout clean or doing other processing.

# Capture everything after "ERROR: " into errors.txt
sed -nE 's/^ERROR:\s+(.*)$/\1/w errors.txt' server.log

5) Multi-line editing by “slurping” lines

Use-case: Join backslash-continued lines or do a substitution that crosses line breaks.

# Join lines ending with backslash "\" into a single logical line:
sed -e ':a' -e '/\\$/N; s/\\\n//; ta' source.txt

# Replace patterns that span a newline (e.g., "foo\nbar" → "foobar"):
sed ':a;N;$!ba; s/foo\nbar/foobar/g' input.txt

Quick Copy-Paste Cheat Sheet

# 1) Dynamic replacements (GNU sed):
sed -E 's/\{\{DATE\}\}/date +%F/e; s/\{\{GIT\}\}/git rev-parse --short HEAD/e' template.txt

# 2) Every n-th line (GNU sed):
sed -n '1~5p' big.log

# 3) Collapse adjacent duplicates:
sed '$!N; /^\(.*\)\n\1$/!P; D' file.txt

# 4) Write matches to a file:
sed -nE 's/^ERROR:\s+(.*)$/\1/w errors.txt' server.log

# 5) Multi-line “slurp” tricks:
sed -e ':a' -e '/\\$/N; s/\\\n//; ta' source.txt
sed ':a;N;$!ba; s/foo\nbar/foobar/g' input.txt
Pro tip: When a one-liner gets gnarly, save it as a script file and run sed -Ef script.sed input.txt for readability and version control.