Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 68 of 899 min

pyproject.toml: making your project installable, and a command somebody can type

From a folder to a thing

Module 3 laid out a project so that imports work. Module 5 pinned its dependencies. What remains is turning it into something that can be installed — by you on another machine, by a colleague, by a server — and run with one word instead of python -m tagger.cli --input ....

The file that does it is pyproject.toml, at the root of the project:

toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "tagger"
version = "0.1.0"
description = "Tag customer messages with a model"
requires-python = ">=3.10"
dependencies = [
    "requests>=2.31,<3",
    "pydantic>=2,<3",
]

[project.optional-dependencies]
dev = ["pytest", "pyright", "ruff"]

[project.scripts]
tagger = "tagger.cli:main"

Every section does one job. [build-system] names the tool that turns the folder into an installable package; setuptools is the default and fine. [project] is the metadata and the runtime dependencies — the same list requirements.txt held, now with version ranges rather than exact pins, because a library states what it is compatible with while a deployment pins what it tested. [project.optional-dependencies] groups the tools you need to work on it but a user does not. [project.scripts] is the command.

TOML is the format: sections in square brackets, key = value, strings quoted, lists in square brackets. It reads like an INI file with rules.

The layout it expects

tagger/
├── pyproject.toml
├── README.md
├── src/
│   └── tagger/
│       ├── __init__.py
│       ├── cli.py
│       └── core.py
└── tests/
    └── test_core.py

With src/, setuptools finds the package automatically. The reason for src/ was given in module 3: it stops tests from importing the working copy by accident and proves the installed package works. Here it pays off again, because the same layout is what the build tool expects without configuration.

Editable install

bash
pip install -e .

The dot is the current folder. -e is editable: instead of copying files into site-packages, pip writes a link to your src/ folder, so every edit takes effect the next time Python imports the module, with no reinstall. Run it once per virtual environment. Add [dev] to get the development tools too:

bash
pip install -e ".[dev]"

The quotes protect the square brackets from the shell on macOS and Linux.

After this, import tagger works from any directory, tests run against the real package, and tagger is a command.

The command

tagger = "tagger.cli:main" means: when someone types tagger, import tagger.cli and call main(). That function takes no arguments — it reads sys.argv through argparse, as module 3 taught — and its return value becomes the exit code if it is an integer.

python
# src/tagger/cli.py
import argparse

def main() -> int:
    p = argparse.ArgumentParser(prog="tagger")
    p.add_argument("input")
    p.add_argument("--model", default="qwen2.5:0.5b")
    args = p.parse_args()
    ...
    return 0

What pip generates is a tiny script on your PATH — in the venv's bin/ or Scripts/ — that does the import and the call. Because the import is live, edits to main's body are picked up. Because the name main was written into that script when it was generated, renaming the function breaks the command until you reinstall. That is the one edit an editable install does not follow.

Building a distribution

bash
pip install build
python -m build

produces dist/tagger-0.1.0-py3-none-any.whl and a .tar.gz. The wheel is a zip file with a manifest; pip install dist/tagger-0.1.0-py3-none-any.whl installs it on any machine with the right Python, no source tree needed. This is what you hand to a server, or upload.

twine upload --repository testpypi dist/* publishes to TestPyPI, a free sandbox where the name does not need to be unique forever. Real PyPI is one flag away, and once a name is taken there it is taken; do not claim one you will not maintain.

pipx

A command-line tool wants its own environment, so that its dependencies never collide with a project's. pipx install tagger (or pipx install . for a local one) creates a private venv for it and puts only the command on your PATH. It is how you should install any Python tool you run globally — ruff, pyright, black — and how a colleague should install yours.

Version and the other files

Bump version before each build; pip will not replace an installed 0.1.0 with a different 0.1.0. readme = "README.md" in [project] puts the README on the package page. A LICENSE file is expected if you publish; without one, nobody may legally use it, whatever you intended.

uv and poetry from module 5 read the same pyproject.toml and add lockfiles and faster resolvers on top. The file is the standard; the tools are choices.

Try this now

Give the project from module 3 a pyproject.toml, install it editable, and run your command from a different directory. Rename main, watch the command fail, reinstall, and watch it work. Then python -m build and look inside the wheel with unzip -l.

The one thing to keep

A pyproject.toml with a name, version, dependencies and a [project.scripts] entry turns a folder into something pip can install and a command a colleague can run; pip install -e . links the source so edits take effect without reinstalling.

Before you move on

A developer adds `[project.scripts] tagger = "tagger.cli:main"` to `pyproject.toml`, runs `pip install -e .`, and typing `tagger` in the terminal works. They then rename `main` to `run` inside `cli.py`, and the `tagger` command fails with `AttributeError: module 'tagger.cli' has no attribute 'main'`. Why did the editable install not pick up the change?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly