Backend
I Built a Python CLI Toolbox Instead of Writing One-Off Scripts
Mehak. DEV Community
2 views
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
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.
From npm to uv
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.
Structuring the CLI
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.
The commands
CSV ↔ Excel
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.
Image utilities
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.
Compressing PDFs
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:
# 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.
Testing
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.
Installing it
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.
What I learned
Three things stuck:
uv makes Python project setup feel like npm, which removed most of my friction as a Node developer
argparse is enough for a real CLI , I never needed a framework
rasterization is the escape hatch when normal PDF compression isn't enough
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.
GitHub Repository
Read original: https://dev.to/mehakb7/i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts-2f7i
← Previous
🚀 Crowdwide Just Became a KODA Season 2 Champion 👑
Next →
The Manifest Is the Only Declaration
Related
🚀 Crowdwide Just Became a KODA Season 2 Champion 👑
Backend
2
DEV Community
Browser Fingerprinting in 2026: What Still Works, What Doesn't
Backend
3
Dev.to (EN Zone)
Sa-Token necessary implement StpInterface
Backend
2
DEV Community
New to Website building can you please help me, how much should I quote for this
Backend
6
Reddit r/webdev
Comments0
No comments yet — be the first