Rain Lag

From Tiny CLI Tool to Polished Python Utility: A Beginner’s Guide to Shipping Something Real

Learn how to turn a quick Python script into a proper, installable command-line utility using modern packaging with pyproject.toml, entry points, and best practices for publishing.

From Tiny CLI Tool to Polished Python Utility: A Beginner’s Guide to Shipping Something Real

Most developers have a folder full of tiny Python scripts that solve real problems—but only for them. Maybe it’s a log cleaner, a CSV formatter, or a quick backup script. It works… as long as you remember the exact command to run it, where you saved it, and which virtual environment it lives in.

The good news: you can turn that scrappy script into a real, installable command-line tool—the kind you run as logcleaner from anywhere on your system. This guide walks you from “tiny script” to “polished Python utility” using modern packaging practices.

We’ll focus on:

  • Using pyproject.toml and modern packaging tools
  • Adding CLI arguments and options
  • Exposing functions as commands via [project.scripts]
  • Installing in editable mode for smooth development
  • Getting ready to publish to PyPI or a private index

1. Start with a simple Python script

Let’s imagine you have a quick script that cleans up log files:

# logcleaner.py import os from pathlib import Path LOG_DIR = Path("/var/log/myapp") for log_file in LOG_DIR.glob("*.log"): if log_file.stat().st_size == 0: log_file.unlink() print(f"Deleted empty log: {log_file}")

It works, but:

  • You must remember the path to logcleaner.py.
  • You can’t easily pass options (like a different directory or a --dry-run).
  • You can’t install it for others to use.

The goal is to turn this into something you can install and run as:

logcleaner --path /var/log/myapp --dry-run

2. Add a proper command-line interface

To treat the script as a first-class CLI tool, we need an interface that accepts arguments and options. The standard library’s argparse is a great starting point.

Let’s refactor into a small package structure and a main.py entry module.

Project structure:

logcleaner/ ├─ src/ │ └─ logcleaner/ │ ├─ __init__.py │ └─ main.py └─ pyproject.toml # we’ll add this next

src/logcleaner/main.py:

from __future__ import annotations import argparse from pathlib import Path def clean(path: str | Path, dry_run: bool = False) -> None: log_dir = Path(path) if not log_dir.exists() or not log_dir.is_dir(): raise SystemExit(f"Error: {log_dir} is not a valid directory") for log_file in log_dir.glob("*.log"): if log_file.stat().st_size == 0: if dry_run: print(f"[DRY RUN] Would delete: {log_file}") else: log_file.unlink() print(f"Deleted empty log: {log_file}") def main() -> None: parser = argparse.ArgumentParser(description="Clean up empty log files.") parser.add_argument( "path", nargs="?", default="/var/log/myapp", help="Path to the log directory (default: /var/log/myapp)", ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be deleted without removing anything", ) args = parser.parse_args() clean(path=args.path, dry_run=args.dry_run) if __name__ == "__main__": # still works as `python -m logcleaner.main` main()

Now we have:

  • A reusable function clean() that can be tested directly.
  • A main() function that handles command-line parsing.
  • A structure suitable for packaging.

You could also use libraries like Click, Typer, or Argparse + Rich for more ergonomic CLIs, but argparse is perfectly fine to start.


3. Use pyproject.toml and modern packaging

Modern Python packaging centers around pyproject.toml instead of (or in addition to) legacy setup.py. This file describes your project metadata, dependencies, and how to build it.

Create pyproject.toml in the project root:

[build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "logcleaner" version = "0.1.0" description = "A tiny CLI tool to clean up empty log files." readme = "README.md" requires-python = ">=3.9" authors = [ { name = "Your Name", email = "you@example.com" } ] license = { text = "MIT" } keywords = ["logs", "cli", "utilities"] # If you have dependencies, list them here # dependencies = [ # "rich>=13.0.0", # ] [project.urls] Homepage = "https://github.com/yourname/logcleaner" [project.scripts] logcleaner = "logcleaner.main:main"

The critical part for a CLI is the [project.scripts] section:

[project.scripts] logcleaner = "logcleaner.main:main"

This tells the packaging system:

When this project is installed, create an executable script called logcleaner that calls the main function in logcleaner/main.py.

You can map multiple commands:

[project.scripts] logcleaner = "logcleaner.main:main" logcleaner-clean = "logcleaner.main:clean" # calls the function directly

This is how you turn regular Python functions into first-class command-line commands.


4. Install in editable (development) mode

During development, you don’t want to reinstall your package every time you change the code. Editable mode solves this by linking the installed package to your source directory.

From the project root, run:

python -m pip install -e .

This does two things:

  1. Installs logcleaner in your environment.
  2. Creates the logcleaner command, using the [project.scripts] entry.

Now you can run:

logcleaner /tmp/logs --dry-run

Any changes you make under src/logcleaner are reflected immediately without reinstalling, as long as you stay in the same environment. This is the standard workflow for developing Python CLIs.


5. Versioning and packaging best practices

If you want to someday publish your utility—whether to PyPI or a private repository—it pays to follow a few simple best practices early.

Choose a versioning scheme

Use a predictable scheme like Semantic Versioning:

  • MAJOR.MINOR.PATCH1.4.2
  • Increment:
    • PATCH for bug fixes
    • MINOR for new features that don’t break existing usage
    • MAJOR for breaking changes

Keep the version in one place—typically in pyproject.toml under [project] version.

Build your distribution artifacts

Use the build package to create distributable files:

python -m pip install build python -m build

This generates:

  • A source distribution (.tar.gz)
  • A wheel (.whl)

in a dist/ directory. These are what you upload or distribute.

Test your install from a clean environment

Before publishing, always test like an end user would:

python -m venv .venv-test source .venv-test/bin/activate # or .venv-test\Scripts\activate on Windows python -m pip install dist/logcleaner-0.1.0-py3-none-any.whl logcleaner --help

If this works cleanly, you’re in good shape.


6. Getting ready to publish (PyPI or private index)

Once you’re happy with your utility:

  1. Create a README.md that explains:
    • What the tool does
    • How to install it (pip install logcleaner)
    • Basic usage and examples
  2. Optionally add a LICENSE file (MIT, Apache-2.0, etc.).
  3. Check that your metadata in pyproject.toml is accurate (authors, URLs, description).

To publish publicly to PyPI:

python -m pip install twine python -m twine upload dist/*

For a private repository (like an internal Artifactory, Nexus, or simple index), your organization will give you an upload URL. You’ll then install with:

python -m pip install --index-url https://your-private-index/simple logcleaner

Because you’ve followed modern standards, your package should be easy to host anywhere.


7. Use established CLI libraries and patterns

While argparse is great, many production CLIs use established libraries such as:

  • Click – declarative decorators, great for nested commands.
  • Typer – type-hints-first, very ergonomic, built on Click.
  • Rich-Argparse – nicer help and error messages.

These libraries embody common design patterns:

  • Clear separation between business logic and CLI parsing.
  • Consistent --help output.
  • Support for subcommands (git commit, git push, etc.).

You can start with a tiny argparse script and later migrate to Click or Typer without changing your packaging model. The [project.scripts] section still points to one entry function.


8. Polishing the experience

To go from “it works for me” to “this feels finished,” add a few touches:

  • Helpful --help output with examples.
  • Clear error messages using SystemExit with human-friendly text.
  • Logging or verbose modes (--verbose, --quiet).
  • Basic tests for core functionality.
  • A minimal CHANGELOG to track what changed in each version.

None of these need to be perfect, but even a little polish makes your tool feel trustworthy and shareable.


Conclusion: Ship something real

You don’t need a huge project to learn real-world Python packaging. A 50-line script is enough.

By:

  • Structuring your code as a package
  • Adding a simple CLI interface with arguments and options
  • Using pyproject.toml and [project.scripts] to define entry points
  • Installing in editable mode while you iterate
  • Following basic versioning and packaging best practices

…you transform a throwaway script into a polished, installable utility you can share, reuse, and even publish.

Pick one script from your “tools” folder today. Wrap it in a package, add a command, and ship something real—even if it’s tiny.

From Tiny CLI Tool to Polished Python Utility: A Beginner’s Guide to Shipping Something Real | Rain Lag