Skip to main content

Packaging Internal Tools

What You'll Learn

How to turn a Python script into an installable tool with a proper CLI entry point — so anyone on your team can run it with a simple command.

The Problem

Right now, teammates run scripts like:

cd /home/alice/tools
source venv/bin/activate
python3 scripts/process_data.py --input data.csv

This is fragile and hard to share. The goal is:

process-data --input data.csv

From any directory, without activating a venv manually.

pyproject.toml — The Modern Way

The pyproject.toml file describes your package and its entry points:

[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "my-tools"
version = "1.0.0"
description = "Internal data processing tools"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"requests>=2.28",
"python-dotenv",
]

[project.scripts]
process-data = "mytools.processor:main"
run-report = "mytools.reporter:main"
validate-config = "mytools.validator:main"

[project.scripts] defines CLI commands:

  • process-data → calls main() in mytools/processor.py

Project Structure for a Package

my-tools/
├── src/
│ └── mytools/
│ ├── __init__.py ← makes this a package
│ ├── processor.py ← 'process-data' entry point
│ ├── reporter.py ← 'run-report' entry point
│ └── utils.py ← shared helpers
├── tests/
│ ├── conftest.py
│ └── test_processor.py
├── pyproject.toml
├── README.md
└── .gitignore

Writing an Entry Point Function

The entry point must be a function that returns an integer (the exit code):

# src/mytools/processor.py
import argparse
import sys
import logging
from pathlib import Path

log = logging.getLogger(__name__)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Process data files and generate reports"
)
parser.add_argument("--input", "-i", type=Path, required=True,
help="Input CSV file")
parser.add_argument("--output", "-o", type=Path, default=Path("output.csv"),
help="Output file (default: output.csv)")
parser.add_argument("--verbose", "-v", action="store_true")
return parser.parse_args()


def process(input_path: Path, output_path: Path) -> dict:
# business logic here
return {"processed": 100, "errors": 0}


def main() -> int:
logging.basicConfig(level=logging.INFO)
args = parse_args()

if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)

if not args.input.exists():
print(f"Error: input file not found: {args.input}", file=sys.stderr)
return 1

log.info("Processing %s → %s", args.input, args.output)
result = process(args.input, args.output)
log.info("Done: %s", result)
return 0


if __name__ == "__main__":
sys.exit(main())

Installing the Package

Install in editable mode (development)

# From the project root
pip install -e .

"Editable" means changes to source code take effect immediately — no reinstall needed.

# Now available as a command
process-data --help
process-data --input data.csv --output results.csv

Install from a local directory

pip install /path/to/my-tools/

Install from git

pip install git+https://github.com/myorg/my-tools.git
pip install git+https://github.com/myorg/my-tools.git@v1.2.0

Install from a shared internal location

# Build a wheel
pip install build
python3 -m build
# Creates dist/my_tools-1.0.0-py3-none-any.whl

# Share the wheel file
pip install my_tools-1.0.0-py3-none-any.whl

# Or serve via private PyPI (devpi, Nexus, Artifactory)
pip install --index-url https://pypi.internal.myorg.com my-tools

main.py — Enable python -m mytools

Add a __main__.py to run as python -m mytools:

# src/mytools/__main__.py
from .processor import main
import sys

sys.exit(main())
python3 -m mytools # runs __main__.py

Versioning Your Tool

Commit version bumps as releases:

[project]
version = "1.2.0" # bump this

Or use dynamic versioning from git tags:

[project]
dynamic = ["version"]

[tool.setuptools.dynamic]
version = {attr = "mytools.__version__"}
# src/mytools/__init__.py
__version__ = "1.2.0"

Sharing Within a Team

Options ranked by effort:

MethodWhen to use
pip install -e .Local dev
pip install git+https://...Team with git access
Wheel file + pipSmall teams, no infra
Private PyPI (devpi)Large teams, enterprise
Docker image with tool pre-installedServer/CI environments

Common Mistakes

MistakeFix
main() with no return valueReturn 0 (success) or 1 (failure)
No if __name__ == "__main__":Always guard entry points
Package name has hyphensUse underscores in package names (importable)
Missing src/ in pathAdd [tool.setuptools.packages.find] in pyproject.toml

Quick Reference

# pyproject.toml
[project]
name = "my-tools"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = ["requests"]

[project.scripts]
my-tool = "mypackage.module:main"
# Install
pip install -e . # editable (dev)
pip install . # installed copy
pip install git+https://... # from git

# Build and distribute
pip install build
python3 -m build
# → dist/my_tools-1.0.0-py3-none-any.whl

What's Next

Lesson 4: pyproject.toml Basics