{"slug": "i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts", "title": "I Built a Python CLI Toolbox Instead of Writing One-Off Scripts", "summary": "A developer built Toolbox, an open-source Python CLI that bundles reusable file and image utilities including CSV-to-Excel conversion, image grayscale, blur and compression, and PDF compression. The project, created after completing the freeCodeCamp Python certification, uses uv for package management, argparse for command parsing, OpenPyXL for Excel handling, and Pillow for image processing, with a rasterization approach for shrinking PDFs that trades away searchable text.", "body_md": "Whenever I ask an AI assistant to do a small task, it writes a quick Python script for me.\n\nConvert an image.\n\nCompress a PDF.\n\nConvert a CSV to Excel.\n\nThe script works, I use it once, and then it disappears.\n\nAt some point I thought:\n\nWhy keep asking for one-off scripts when I could build my own reusable toolbox?\n\nSo I did.\n\n**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)\n\nI 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.\n\nThe first thing I had to figure out wasn't Python syntax. It was package management.\n\nIn Node I install with `npm install`, dependencies live in `package.json`, and versions lock in `package-lock.json`.\n\nIn 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.\n\nThen 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:\n\n| Node.js | uv | \n|---|---|\n| `npm init` | `uv init` | \n| `package.json` | `pyproject.toml` | \n| `package-lock.json` | `uv.lock` | \n| `npm install <pkg>` | `uv add <pkg>` | \n| `npm ci` | `uv sync` | \n| `npx <tool>` | `uvx <tool>` | \n| `npm install -g .` | `uv tool install .` | \n\nI 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.\n\nThat's the point where Python started feeling normal to me.\n\nI organized Toolbox like this:\n\n```\ntoolbox/\n├── src/\n│   └── toolbox/\n│       ├── commands/\n│       ├── utils/\n│       └── __init__.py\n├── tests/\n├── pyproject.toml\n└── uv.lock\n```\n\nCommands go in `commands/`, reusable helpers in `utils/`.\n\nFor 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:\n\n```\n# src/toolbox/__init__.py\nblur_parser = subparser.add_parser(\n    \"blur\",\n    help=\"Apply a Gaussian blur to an image.\"\n)\nblur_parser.add_argument(\"input\", help=\"Path to the image file to blur.\")\nblur_parser.add_argument(\"-o\", \"--output\", help=\"Output path (default: add _blured to file name)\")\nblur_parser.add_argument(\n    \"-r\", \"--radius\",\n    type=int, default=5,\n    help=\"Blur strength — higher values mean a stronger blur (default: 5).\"\n)\n```\n\nThat gives me positional arguments, optional flags, defaults, type coercion and generated help text for free:\n\n```\ntoolbox blur image.png --radius 60\n```\n\nThe image is required. The radius isn't.\n\nPython'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.\n\nFor images I used **Pillow**. Right now: `grayscale`, `blur`, `compress`.\n\nThe 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.\n\nThis 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.\n\nThat's where rasterization comes in. Instead of preserving the PDF structure, you flatten it:\n\n```\nPDF\n ↓\nconvert pages to images\n ↓\ncompress the images\n ↓\nbuild a new PDF\n```\n\nThe trade-off is real, though: the output no longer has searchable or selectable text. It's a picture of your document.\n\nSo 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.\n\nI used **PyMuPDF** with Pillow for this. Here's the actual rasterize function:\n\n``` python\n# src/toolbox/commands/compress_pdf.py\nfrom pathlib import Path\nimport tempfile\nimport pymupdf\nfrom PIL import Image\n\ndef _compress_pdf_rasterize(input_path: str, output_path: str, dpi: int, quality: int):\n    docs = pymupdf.open(input_path)\n    with tempfile.TemporaryDirectory() as dir:\n        pages_path = []\n        for index, page in enumerate(docs):\n            pix = page.get_pixmap(dpi=dpi)\n            image = Image.frombytes(\"RGB\", [pix.width, pix.height], pix.samples)\n            path = Path(dir) / f\"page{index}.JPG\"\n            image.save(path, quality=quality)\n            pages_path.append(path)\n\n        new_docs = pymupdf.open()\n        for path in pages_path:\n            img = pymupdf.open(path)\n            img_pdf = pymupdf.open(\"pdf\", img.convert_to_pdf())\n            new_docs.insert_pdf(img_pdf)\n            img.close()\n            img_pdf.close()\n        new_docs.save(output_path)\n```\n\nA 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.\n\nI used **pytest**. The current tests mostly cover the happy paths.\n\nThere'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.\n\nThe last piece was making Toolbox behave like a real CLI rather than a folder of scripts. With uv:\n\n```\nuv tool install .\n```\n\nAfter that it's just:\n\n```\ntoolbox grayscale image.png\n```\n\nNo remembering which file implements which command.\n\nThree things stuck:\n\n`argparse` is enough for a real CLI , I never needed a framework\nBut the bigger lesson wasn't about Python at all:\n\nA useful CLI shouldn't just call a library function. It should make reasonable decisions around the user's actual problem.\n\nThe 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.\n\nIt 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.", "url": "https://wpnews.pro/news/i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts", "canonical_source": "https://dev.to/mehakb7/i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts-2f7i", "published_at": "2026-09-13 15:51:51+00:00", "updated_at": "2026-09-13 16:14:31.552662+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Toolbox", "uv", "OpenPyXL", "Pillow", "freeCodeCamp", "GitHub", "Node.js", "Python"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts", "markdown": "https://wpnews.pro/news/i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts.md", "text": "https://wpnews.pro/news/i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts.txt", "jsonld": "https://wpnews.pro/news/i-built-a-python-cli-toolbox-instead-of-writing-one-off-scripts.jsonld"}}