Packaging & distributing a tool
From a script to something teammates can install.
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.
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.
[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.
import reimport 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")
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.
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.
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.
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.
# 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.whlENTRYPOINT ["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.