Packaging & distributing a tool

From a script to something teammates can install.

Intermediate20 min · lesson 13 of 14

You wrote a Python script that reads a log file and prints the suspicious IP addresses (Internet Protocol addresses, the numeric labels like 10.0.0.5 that pick out one machine on a network) hiding inside it. It works on your laptop. Then a teammate asks for it, and the trouble starts. They copy your folder, guess which Python to run, hit a missing library, and give up. During an incident that costs real time, because the person who needs the tool is rarely the person who wrote it, and the clock is running.

A loose script is a recipe scribbled on a napkin. It makes sense to the cook who wrote it and to almost nobody else. Packaging turns that napkin into a boxed meal kit: a label on the outside, the ingredients listed, and steps any teammate can follow to reach the same result. In Python that label is a single file named pyproject.toml, and this lesson turns one script into something a colleague installs with one command and runs by name.

The shape of a package

Give the project a small, predictable layout. The example here is a tool called iocflag, a short program that flags IOCs (indicators of compromise, the giveaway traces, a suspicious IP or a file hash or a domain, that suggest someone broke in). Your code goes inside a package (a folder Python can import, marked by an __init__.py file that tells Python the folder is importable) that sits under a src/ directory, and the label sits at the top level.

text
iocflag/
├── pyproject.toml
└── src/
└── iocflag/
├── __init__.py
└── cli.py

The src/ layout is not decoration. Without it, running Python from the project root can import your package straight out of the working directory and hide a broken install, so your tests pass for you and fail for everyone else. Testing from src/ is like tasting the sealed meal kit a customer actually receives instead of the open pot on your own stove: it forces you to check the installed package, the exact one your teammates get.

pyproject.toml is written in TOML (Tom's Obvious Minimal Language, a plain configuration format that reads like a labeled list). It is the bill of materials for your tool: its name, its version, which Python it needs, what it depends on, and the command it should expose. Here is a working one.

pyproject.toml
[project]
name = "iocflag"
version = "0.1.0"
description = "Flag suspicious IPs in a text file"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
iocflag = "iocflag.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

Three sections carry the weight. [project] is the identity: the name people type after pip install, the version, and requires-python, which stops the tool from installing on an interpreter too old to run it. dependencies lists the outside libraries pip should pull in, and keeping that list short and pinned to exact versions is a security habit, because every entry is code you agree to run on your machine. [build-system] tells pip which tool assembles the package (setuptools here). And [project.scripts] is the interesting one.

A name on the PATH

Right now your code lives at src/iocflag/cli.py. Nobody wants to type that. A console script gives your tool a short name that works from any directory, the way a doorbell labeled with your name lets a visitor reach you without knowing which room you are in. The technical term is an entry point: one line that maps a command name to a function. Older setuptools called this list console_scripts, and [project.scripts] is the modern spelling of the same idea.

The line iocflag = "iocflag.cli:main" reads left to right: make a command called iocflag; when someone runs it, import the iocflag.cli module and call its main() function. At install time pip writes a tiny launcher with that name into a folder already on your PATH (the list of directories your shell searches when you type a command). The launcher's first line points at the exact Python that owns the package, so the tool always runs against its own libraries. The command is now iocflag, with no file path and no python typed in front of it.

cli.py
import re
import sys
# Match anything shaped like an IPv4 address (Internet Protocol
# version 4: four numbers joined by dots, like 10.0.0.5).
IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
def main():
if len(sys.argv) < 2:
print("usage: iocflag FILE")
raise SystemExit(2)
with open(sys.argv[1], encoding="utf-8", errors="replace") as f:
text = f.read()
hits = sorted(set(IPV4.findall(text)))
for ip in hits:
print(f"[ioc] {ip}")
print(f"{len(hits)} indicator(s) found")
From one script to something teammates can install
1Loose script
cli.py on your laptop
2pyproject.toml
name, version, entry point
3pip install -e .
edit live while you build
4python -m build
wheel + sdist artifacts
5pipx / container
isolated install for others
The same source flows one way: describe it once in pyproject.toml, develop against it live, then produce a fixed artifact others install in isolation.

Install it while you build: pip install -e .

You do not want to rebuild and reinstall after every edit. An editable install (the -e flag, short for editable) fixes that. It installs a pointer back to your source folder instead of a copy, like hanging a mirror where the command used to be: the installed iocflag reflects whatever your files currently say. Change cli.py, run iocflag again, and you see the new behavior with no reinstall.

Do this inside a virtual environment (a venv, a private throwaway copy of Python built for this one project, walled off like a workbench you can sweep clean and rebuild) so nothing you install touches the system Python your operating system depends on. Recent Debian and Ubuntu releases refuse a plain pip install outside a venv for exactly that reason.

~/secopslog — bash
$ python3 -m venv .venv && source .venv/bin/activate (.venv) $ pip install -e .
Obtaining file:///home/analyst/iocflag Installing build dependencies ... done Checking if build backend supports build_editable ... done Getting requirements to build editable ... done Preparing editable metadata (pyproject.toml) ... done Building wheels for collected packages: iocflag Building editable for iocflag (pyproject.toml) ... done Created wheel for iocflag: filename=iocflag-0.1.0-0.editable-py3-none-any.whl size=1560 sha256=9638de3f... Stored in directory: /tmp/pip-ephem-wheel-cache-gu0rwrn_/wheels/... Successfully built iocflag Installing collected packages: iocflag Successfully installed iocflag-0.1.0
$ (.venv) $ echo "beacon to 185.220.101.42 and 10.0.0.5, also 185.220.101.42" > sample.log (.venv) $ iocflag sample.log
[ioc] 10.0.0.5 [ioc] 185.220.101.42 2 indicator(s) found

The command runs, dedupes and sorts the addresses, and lives on your PATH only inside this venv. That containment is the payoff: the tool and its dependencies cannot drift into other projects or into the system Python, so nothing you install here can quietly change how a different tool behaves.

Build a handoff artifact: python -m build

An editable install is for you. To hand the tool to someone else, you build a file they can install with no access to your source tree. python -m build produces two of them. A wheel (a pre-built package, ready to unzip straight into place) is flat-pack furniture: the cutting is already done, so installing it is fast and runs no build step. An sdist (a source distribution, a compressed tarball of your raw source) is the raw lumber: it builds anywhere but has to be assembled at install time.

~/secopslog — bash
$ (.venv) $ pip install build (.venv) $ python -m build
* Creating isolated environment: venv+pip... * Installing packages in isolated environment: - setuptools>=68 * Getting build dependencies for sdist... ... (setuptools egg_info / manifest output) ... * Building sdist... * Building wheel from sdist * Creating isolated environment: venv+pip... * Installing packages in isolated environment: - setuptools>=68 * Getting build dependencies for wheel... * Building wheel... Successfully built iocflag-0.1.0.tar.gz and iocflag-0.1.0-py3-none-any.whl
$ (.venv) $ ls dist/
iocflag-0.1.0-py3-none-any.whl iocflag-0.1.0.tar.gz

For a security team the wheel earns its keep beyond convenience. It is a single fixed file you can hash (run through a one-way function that turns the file's bytes into a short, unique fingerprint), record in an SBOM (a software bill of materials, the itemized list of everything inside a build), sign, and store in an internal package index. The py3-none-any in the filename means it is pure Python and works on any platform. Give teammates that file and they can check its hash against what you published, proving the tool they are about to run is the one you built.

Ship end-user tools with pipx, not pip

pip install drops a package into whatever environment is active and shares one set of libraries with everything else in it. For a command-line tool (a program you run by typing its name, often shortened to CLI) that teammates only want to run, that sharing is a liability: two tools that need different versions of the same library will fight over it. pipx (read it as pip for standalone apps) gives every tool its own private apartment. It creates a separate hidden virtual environment per tool, installs the tool there, and puts only the command on your PATH. Each tool keeps its own dependencies, and nothing leaks in or out.

That isolation is why pipx is the right default for end-user tools. It keeps you away from sudo pip install, which runs a package's build steps as root and lets one bad dependency own the whole machine. It sidesteps the externally-managed-environment error that recent Linux distributions raise to protect the system Python. And uninstalling a tool takes its dependencies with it, rather than leaving orphaned libraries behind in a shared folder. You install straight from the wheel you built.

~/secopslog — bash
$ pipx install ./dist/iocflag-0.1.0-py3-none-any.whl
installed package iocflag 0.1.0, installed using Python 3.11.9 These apps are now globally available - iocflag done! ✨ 🌟 ✨
$ iocflag sample.log
[ioc] 10.0.0.5 [ioc] 185.220.101.42 2 indicator(s) found
Installing a package runs its code
pip install and pipx install are not passive downloads. To build an sdist, pip runs the package's build backend, and many packages execute their own setup code along the way. Installing an untrusted sdist carries the same risk as running an untrusted script: arbitrary code execution on your machine, as the user who ran the command. Install only from sources you trust, prefer wheels (which skip the build step) from an index you control, and pin exact versions so nobody can swap a malicious release under a version you already approved.

One caution before you rely on it: the separation pipx gives you is for dependency hygiene, not containment. A tool installed with pipx still runs with your full user permissions. It can read your files, reach the network, and use your saved credentials. Do not treat pipx isolation as a wall around code you do not trust. For code you truly cannot trust you need a real sandbox or a container, which is the next step.

One more step for reproducibility: a container

Even pipx leans on whatever Python and system libraries the host happens to have. When you need the exact same tool to behave identically on a laptop, a build runner, and a production server, put the wheel inside a container image. A container image is a sealed lunchbox for software: it freezes the interpreter, the operating-system libraries, and your tool together as one artifact that runs the same wherever you open it.

Dockerfile
# Pin the base image by its digest instead of only a tag,
# so every rebuild is byte-for-byte the same.
FROM python:3.11-slim@sha256:<digest>
COPY dist/iocflag-0.1.0-py3-none-any.whl /tmp/
RUN pip install --no-cache-dir /tmp/iocflag-0.1.0-py3-none-any.whl
ENTRYPOINT ["iocflag"]

Pinning the base image by digest (the sha256 hash after the @, a fixed-length fingerprint of the image's exact bytes that you copy from the registry and drop in place of the placeholder) is the security detail. A tag like 3.11-slim can point at a fresh image tomorrow; a digest always points at the same bytes, so a rebuild in the middle of an incident hands you the tool you tested, not a surprise. Build the image once, and anyone who has it runs your tool without touching their own Python.

~/secopslog — bash
$ docker build -t iocflag .
[+] Building 4.7s (9/9) FINISHED => [1/3] FROM docker.io/library/python:3.11-slim@sha256:... => [2/3] COPY dist/iocflag-0.1.0-py3-none-any.whl /tmp/ => [3/3] RUN pip install --no-cache-dir /tmp/iocflag-0.1.0-py3-none-any.whl => exporting to image => => naming to docker.io/library/iocflag:latest
$ docker run --rm -v "$PWD:/data" iocflag /data/sample.log
[ioc] 10.0.0.5 [ioc] 185.220.101.42 2 indicator(s) found
Quick check
01You built a security CLI and want teammates to install it without disturbing the other Python tools already on their machines. Why reach for pipx instead of a plain pip install?
Incorrect — pipx compiles nothing; the tool still runs on the same CPython interpreter, at the same speed.
Correct — Per-tool isolation is the whole point, and it also keeps you off sudo pip and out of the externally-managed-environment error.
Incorrect — There is no encryption involved. pipx isolates dependencies; it does not protect files at rest.
Incorrect — pipx does not sign anything. Signing and provenance are separate steps you add with tools like twine or Sigstore.
02In pyproject.toml the line `iocflag = "iocflag.cli:main"` sits under [project.scripts]. What does it tell the installer to do?
Incorrect — main() is not executed at install time; the entry point only registers a command you invoke later.
Incorrect — No file is renamed; the entry point maps a command name to a function and leaves the source layout alone.
Incorrect — It places no restriction on imports; it just registers a console command.
Correct — The left side is the command name and the right side is module:function, so pip writes a launcher named iocflag that calls main().
03Your Dockerfile starts with `FROM python:3.11-slim`. Weeks later you rebuild the image during an incident and the tool behaves differently from the version you tested. What change prevents this?
Incorrect — Fetching fresh layers makes drift more likely, not less; you want the same bytes every time.
Correct — A tag can be repointed at a new image, but a digest is a fixed fingerprint that always resolves to one image.
Incorrect — `latest` moves even more often than a version tag, so it worsens reproducibility rather than fixing it.
Incorrect — The drift comes from the moving base image, not from how the wheel is installed; the fix is pinning the base image by digest.

Related