# I Built a Python CLI Toolbox Instead of Writing One-Off Scripts

> Source: <https://dev.to/mehakb7/i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts-2f7i>
> Published: 2026-09-13 15:51:51+00:00

Whenever I ask an AI assistant to do a small task, it writes a quick Python script for me.

Convert an image.

Compress a PDF.

Convert a CSV to Excel.

The script works, I use it once, and then it disappears.

At some point I thought:

Why keep asking for one-off scripts when I could build my own reusable toolbox?

So I did.

**Toolbox** is a small Python CLI with utilities for the file and image tasks I keep needing — CSV ↔ Excel conversion, image grayscale/blur/compress, and PDF compression. Code here: [GitHub Repository](https://github.com/MehakB7/toolbox)

I had just finished the freeCodeCamp Python certification and wanted to use it for something instead of stopping at the certificate. My background is mainly Node.js, so this also turned into a tour of how Python projects differ from what I already know.

The first thing I had to figure out wasn't Python syntax. It was package management.

In Node I install with `npm install`, dependencies live in `package.json`, and versions lock in `package-lock.json`.

In Python I first came across `pip` with `requirements.txt` — you install things, then freeze them into a file yourself with `pip freeze > requirements.txt`. Coming from Node, generating a lockfile by hand felt backwards.

Then I found **uv**, a modern Python package and project manager that handles project creation, dependencies, virtual environments and lockfiles. Most of it mapped straight onto what I already knew:

| Node.js | uv | 
|---|---|
| `npm init` | `uv init` | 
| `package.json` | `pyproject.toml` | 
| `package-lock.json` | `uv.lock` | 
| `npm install <pkg>` | `uv add <pkg>` | 
| `npm ci` | `uv sync` | 
| `npx <tool>` | `uvx <tool>` | 
| `npm install -g .` | `uv tool install .` | 

I never had to activate a virtual environment or regenerate a requirements file. And anyone cloning the project runs `uv sync` and gets the same environment I have.

That's the point where Python started feeling normal to me.

I organized Toolbox like this:

```
toolbox/
├── src/
│   └── toolbox/
│       ├── commands/
│       ├── utils/
│       └── __init__.py
├── tests/
├── pyproject.toml
└── uv.lock
```

Commands go in `commands/`, reusable helpers in `utils/`.

For argument parsing I used `argparse` from the standard library — no framework needed. There's no per-command `register()` function or plugin pattern here; `__init__.py` just builds one subparser per command directly:

```
# src/toolbox/__init__.py
blur_parser = subparser.add_parser(
    "blur",
    help="Apply a Gaussian blur to an image."
)
blur_parser.add_argument("input", help="Path to the image file to blur.")
blur_parser.add_argument("-o", "--output", help="Output path (default: add _blured to file name)")
blur_parser.add_argument(
    "-r", "--radius",
    type=int, default=5,
    help="Blur strength — higher values mean a stronger blur (default: 5)."
)
```

That gives me positional arguments, optional flags, defaults, type coercion and generated help text for free:

```
toolbox blur image.png --radius 60
```

The image is required. The radius isn't.

Python's standard library already handles CSV, so I only needed **OpenPyXL** for the Excel side. The command validates both paths, and if you don't pass an output path it generates one for you.

For images I used **Pillow**. Right now: `grayscale`, `blur`, `compress`.

The output path is optional here too . Toolbox builds a sensible filename and keeps the original extension. Since every command needed the same path validation and output-name logic, that moved into `utils/` early.

This was my favorite part, because it came from a problem I actually hit: a PDF that had to be uploaded somewhere with a strict size limit, and normal PDF compression wasn't reducing it enough.

That's where rasterization comes in. Instead of preserving the PDF structure, you flatten it:

```
PDF
 ↓
convert pages to images
 ↓
compress the images
 ↓
build a new PDF
```

The trade-off is real, though: the output no longer has searchable or selectable text. It's a picture of your document.

So the command doesn't just rasterize by default. It tries normal compression first, checks whether the file actually got smaller, and only then suggests rasterization as the heavier option.

I used **PyMuPDF** with Pillow for this. Here's the actual rasterize function:

``` python
# src/toolbox/commands/compress_pdf.py
from pathlib import Path
import tempfile
import pymupdf
from PIL import Image

def _compress_pdf_rasterize(input_path: str, output_path: str, dpi: int, quality: int):
    docs = pymupdf.open(input_path)
    with tempfile.TemporaryDirectory() as dir:
        pages_path = []
        for index, page in enumerate(docs):
            pix = page.get_pixmap(dpi=dpi)
            image = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
            path = Path(dir) / f"page{index}.JPG"
            image.save(path, quality=quality)
            pages_path.append(path)

        new_docs = pymupdf.open()
        for path in pages_path:
            img = pymupdf.open(path)
            img_pdf = pymupdf.open("pdf", img.convert_to_pdf())
            new_docs.insert_pdf(img_pdf)
            img.close()
            img_pdf.close()
        new_docs.save(output_path)
```

A few things that weren't obvious going in: `get_pixmap()` gives you raw pixel data, not a file, so I hand it to Pillow via `Image.frombytes()` to actually save it as a JPEG. And `insert_pdf()` won't accept an image-backed document directly — I had to convert each rendered page to a real PDF first with `convert_to_pdf()`, then reopen the result as a PDF document, before I could insert it into the final file. I also had to explicitly `.close()` every `pymupdf`/PIL handle before the `TemporaryDirectory` context manager exits, or cleanup fails on Windows with a file-in-use error.

I used **pytest**. The current tests mostly cover the happy paths.

There's plenty of room to expand them — invalid input paths, unsupported file types, corrupted files, bad arguments, output files that already exist. But even the happy-path tests mean I can change a command without manually re-running all of them to check nothing broke.

The last piece was making Toolbox behave like a real CLI rather than a folder of scripts. With uv:

```
uv tool install .
```

After that it's just:

```
toolbox grayscale image.png
```

No remembering which file implements which command.

Three things stuck:

`argparse` is enough for a real CLI , I never needed a framework
But the bigger lesson wasn't about Python at all:

A useful CLI shouldn't just call a library function. It should make reasonable decisions around the user's actual problem.

The check-then-suggest logic in the PDF command is a few lines of code, and it's the difference between a wrapper and a tool.

It started as a small graduation project from a certification. What it really taught me is that a learning project doesn't have to be complicated to be useful — I just had to stop asking for another tiny script and build the thing myself.
