FDE PulseFDE jobs open 441New in 7 days 29Companies hiring 47Remote-friendly 24%Median US pay $216kTop hirer Databricks 125
VI

The newspaper of the Forward Deployed Engineer

Guides

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.

Hands-on: turn a Python script into a command the client team can install and run themselves with Typer
Photo: Startup Stock Photos / CC0

In brief

  • Typer uses standard Python type hints to build CLIs with help and auto-completion for Bash, Zsh, Fish and PowerShell built in.
  • The command is declared in [project.scripts] and points at the app object. The [build-system] table must always be present.
  • pipx and uv tool install put each tool in its own environment, while uvx runs a tool in a temporary one, so the client team avoids dependency conflicts.
ShareLinkedInFacebookX
GraphicFrom loose script to a command the client runs
  1. 1Create a packageuv init --package, keeping the [build-system] table in pyproject.toml
  2. 2Write the command with TyperDeclare parameters with type hints; help and auto-completion come built in
  3. 3Declare [project.scripts]Swap the default entry for a command name pointing to app, e.g. check_data.main:app
  4. 4Build the wheeluv build creates a wheel (plus sdist) in dist/; hand the wheel file to the client
  5. 5Client installs in isolationpipx or uv tool install for long-term use, uvx for one-off runs

What you deliver is a command installed in an isolated environment, not a .py file plus instructions.

Graphic: FDE Times

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 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:

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.

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:

[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.

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):

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

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:

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

Long-term install (pipx, uv tool install)

  • The operations team uses the command daily
  • The command sits on the PATH, callable from cron or scripts
  • Needs an upgrade process when you ship a new version

One-off run (uvx)

  • Analysts who only run it occasionally
  • Leaves nothing behind on the machine
  • Good for demos or letting the client try it out

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.

Was this article useful?

Use with your AI assistantAsk Claude ↗Ask ChatGPT ↗
5 sources
Read next on the roadmap · Stage 1: FoundationsMessy client Excel files: count the missing cells before pandas adds them upA single "not yet" in a money column can make reported revenue lower than it really is, and nobody notices, because pandas treats a missing cell as zero.