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→ callsmain()inmytools/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:
| Method | When to use |
|---|---|
pip install -e . | Local dev |
pip install git+https://... | Team with git access |
| Wheel file + pip | Small teams, no infra |
| Private PyPI (devpi) | Large teams, enterprise |
| Docker image with tool pre-installed | Server/CI environments |
Common Mistakes
| Mistake | Fix |
|---|---|
main() with no return value | Return 0 (success) or 1 (failure) |
No if __name__ == "__main__": | Always guard entry points |
| Package name has hyphens | Use underscores in package names (importable) |
Missing src/ in path | Add [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