# Hands-on: turn a Python script into a command the client team can install and run themselves with Typer

> A script that runs on your laptop is not yet something you can hand over. It becomes a tool when the client team can type one command on their own machine and have it work.

Original: https://fdetimes.net/en/guides/package-python-script-cli-typer/

In your third week on the client site, you have a script, `check_csv.py`, that validates data files before they are loaded into the pipeline.

The client's operations team messages to ask how to run it, and you reply with a long set of instructions: which Python version to install, what to `pip install`, where to put the file.

Two days later they report an error, because their machine already has a different version of one of the libraries.

The code was fine. What broke was the handover. What an FDE should leave behind is a tool the client can run on their own after you have left the site, and for command-line work that means a properly packaged CLI. The five steps below turn a loose script into a command, `check-data` (Vietnamese for "check"), that the client team installs with a single line.

## What you will build, and what you need

The end product is a Python package called `check-data`. It provides one command that takes a CSV file path and a `--max-rows` option, prints the result to the screen and returns an error code when the file fails. You need Python and [uv](https://docs.astral.sh/uv/) on your machine. The validation logic in the example has been stripped out; replace it with your own.

The main tool is Typer, a library that describes itself as a way to build great CLIs that are easy to code, based on Python type hints.

Because Typer relies on standard Python type hints, you declare parameters just as you would when writing an ordinary function. Help and auto-completion for Bash, Zsh, Fish and PowerShell come built in.

The client team gets all of that without you writing an extra line.

## Steps 1–2: create the package and write the command

Typer's packaging guide uses uv to scaffold the package:

```bash
uv init --package check-data
cd check-data
```

This creates `pyproject.toml`, a file `src/check_data/__init__.py` containing a `main()` function that prints a greeting, and a default entry `check-data = "check_data:main"` in `[project.scripts]`. That entry gets replaced in step 3.

Check: open `pyproject.toml` and look for the `[build-system]` table. The Python Packaging User Guide says this table must always be present, whichever build backend you use.

Next, run `uv add typer` to add Typer to the dependencies, then create `src/check_data/main.py`. The version below is minimal: the command skeleton is complete, and only the validation logic is left blank for you to fill in.

```python
from pathlib import Path
import typer

app = typer.Typer()

@app.command()
def check(path: Path, max_rows: int = 1000):
    """Check a CSV file before loading it into the pipeline."""
    ok = True  # replace with real validation logic
    if not ok:
        raise typer.Exit(code=1)
    typer.echo(f"{path}: passed")
```

The `Path` and `int` type hints tell Typer the data type of each parameter. Whether a parameter is required follows ordinary Python function rules: `path` has no default, so it must be supplied, while `max_rows` has `= 1000`, so it can be omitted. Run `--help` to see how Typer presents the two.

(The docstring and comment are in Vietnamese: "Validate the CSV file before loading it into the pipeline" and "replace with real validation logic". The output string `passed` means "passed".)

## Step 3: declare the command, where most mistakes happen

According to the Python Packaging User Guide, if you want a package to install a command, you must declare it in the `[project.scripts]` table. Typer's example takes the form `rick-portal-gun = "rick_portal_gun.main:app"`. For your package, edit the default line that `uv init` created rather than adding a second line with the same command name:

```toml
[project.scripts]
check-data = "check_data.main:app"
```

The left side is the command name users will type, written with hyphens. The right side is the import path, written with underscores, and it must point at the `app` object, not at the `check` function.

The reason lies in how entry points work. Per the Packaging Guide, running the command is equivalent to importing the referenced object, calling it, and passing its return value to `sys.exit`.

That call passes no arguments. So if you point directly at `check`, the command will most likely fail as soon as it runs, for lack of arguments, instead of going through Typer to parse the command line.

The mechanism reveals something else: a CLI's exit code is a contract between you and the client's systems. Their cron jobs, CI or orchestration scripts read that number to decide whether to continue.

**Key point:** A command-line tool's exit code is a promise to every system that calls it.

Check: temporarily change `ok = True` to `ok = False` to simulate a bad file, then run it through `uv run` (which installs the package into the project environment before running):

```bash
uv run check-data --help
uv run check-data broken_data.csv
echo $?   # expect 1, not 0
```

The comment reads "expect 1, not 0".

## Steps 4–5: build the wheel and ship it so the client has nothing to worry about

```bash
uv build
```

This creates two files in `dist/`: a `.tar.gz` sdist and a `.whl` wheel. The wheel is what you send to the client or push to their internal package repository. At this point the question is no longer a technical one: how are the client team's machines set up?

That question matters, because the error at the start of this piece came from installing into the shared system Python.

The uv documentation explains that `uv tool install` puts each tool in its own environment, so the dependencies of tools, scripts and projects do not conflict. pipx does the same for end-user Python applications: each gets its own virtualenv, and its command is put on the PATH.

`uvx`, by contrast, runs a tool in a temporary, isolated environment, so users need not install anything first. For the wheel you just built, the two invocations are:

```bash
uv tool install ./dist/check_data-0.1.0-py3-none-any.whl
uvx --from ./dist/check_data-0.1.0-py3-none-any.whl check-data data.csv
```

On the client side, settle on one rule: whoever uses the command every day installs it long-term; whoever only tries it out uses `uvx`. Document both in the README, one command each. Do not make them read Python installation instructions.

## Mistakes that kill a tool after you leave the site

The first is giving the command a generic name such as `check` or `run`, which then clashes with something already on the client's machine. Choose a name tied to the business task, such as `check-orders` ("check orders").

The next is code that always exits with 0, even after it has found bad data. A person watching the screen sees the warning, but the client's pipeline carries on loading the bad file.

The third is letting the client install the wheel directly into the system Python, exactly as in the opening scenario. Your tool then shares libraries with everything else on the machine, and a single upgrade is enough to break it. The README should list only pipx, `uv tool install` or `uvx`, never a bare `pip install`.

The last is testing only on your own machine, where every dependency is already present. Before shipping, install the wheel on a clean machine or container using exactly the command in your README.

## How this skill shows up in FDE work

On a client site, the gap between "the demo works" and "the client can run it themselves" often comes down to small things like this. A CLI with a clear `--help`, auto-completion and correct exit codes sharply cuts the number of "how do I run this?" messages after you leave. The client can also wire it into cron or CI without calling you.

When reading FDE job descriptions, look for requirements around internal tooling or handing work over for client teams to operate themselves: that is where this skill gets used.

On your CV, do not write "knows Python packaging". Write that you packaged a data validation tool as a CLI so the client's operations team could run it themselves every day, and say who used it.

The first script worth packaging is the one you have had to explain how to run most often this week.

**Try this week:**

- Take a script you often send clients over chat, package it using the five steps in this guide, and test the install yourself with uv tool install on a clean machine.
- Write a README exactly one screen long, with the install command, one example command and what the exit codes mean, then ask someone who does not code to follow it.
- Add a line to your CV describing an internal tool you handed over, with the number of people or teams who now use it on their own.

## Sources

- [Typer](https://typer.tiangolo.com/)

- [Building a Package - Typer](https://typer.tiangolo.com/tutorial/package/)

- [Writing your pyproject.toml](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)

- [pipx](https://pipx.pypa.io/stable/)

- [Using tools](https://docs.astral.sh/uv/guides/tools/)
