From a Python Script to a Command-Line Tool - 1/9
Starting with a tiny Python HTTP client and identifying the contracts that turn a useful script into a reliable command-line tool.
A useful script and a usable command-line tool are separated by a handful of contracts: where input comes from, how failures are reported, how dependencies are installed, and whether users can invoke the program without knowing where its source file lives.
This series follows those contracts through a deliberately small project. We will start with a Python script that sends one HTTP request, then add arguments, a proper command-line interface, packaging, tests, documentation, and release automation. Keeping the program small makes the engineering decisions easier to see.
When I was learning Python, I kept small experiments in a scripts folder. Most remained disposable. The interesting moment came when one became useful enough that I wanted to invoke it from anywhere and share it with other people. That is where the hidden engineering work began.
The project is tinyHTTPie, a minimal HTTP client inspired by HTTPie.
Series
- From a Script to a Command-Line Tool
- When
sys.argvStops Scaling - Designing a CLI with
argparse - Why a Bash Installer Becomes a Liability
- Packaging a Python CLI with Console Scripts
- Testing a Python CLI and Automating CI
- Documentation as Part of the Product
- Building a Reliable PyPI Release Pipeline
- Publishing a Pure-Python CLI with Conda
Start with the smallest useful program
We will use Pipenv for this first stage. Creating the directory and installing Requests gives the script an isolated environment and records its dependency in the project’s Pipfile:
$ mkdir tinyHTTPie
$ cd tinyHTTPie
$ pipenv install requests
$ touch tihttp.py
The first version has one responsibility: send a GET request and print the response headers.
import requests
URL = "https://the-coding-lab.com/"
response = requests.get(URL, timeout=10)
response.raise_for_status()
for name, value in sorted(response.headers.items()):
print(f"{name}: {value}")
Two details matter even in a small example. Requests does not time out unless we ask it to, so an unresponsive server could leave the process waiting indefinitely. raise_for_status() turns a 4xx or 5xx response into an explicit failure instead of letting the program continue as if the request had succeeded. We will design friendlier error output later in the series.
Requests already exposes response.headers as a mapping-like object. Sorting its items is sufficient for stable, readable terminal output; converting it through OrderedDict first adds no value here.
Run the script inside its Pipenv environment without activating a subshell:
$ pipenv run python tihttp.py
The exact headers depend on the server and when the request is made, but the output will look similar to this:
Cache-Control: max-age=600
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
...
We now have a working program, but not yet a useful command. Its URL is hard-coded, its output is fixed, and callers must know both the file location and how to recreate its Python environment. These are not cosmetic shortcomings; they are missing interface and distribution contracts.
Executable does not mean portable
On Unix-like systems, a shebang and executable permission let us run a Python file directly:
#!/usr/bin/env python3
$ chmod +x tihttp.py
$ ./tihttp.py
The env form asks the operating system to find python3 on the current PATH. It is more portable than embedding the absolute path of one virtual environment, but it still assumes that the selected interpreter has Requests installed. Running pipenv run python tihttp.py makes that environment choice explicit.
Embedding the Pipenv interpreter’s absolute path in the shebang can be convenient on one machine, but it couples the command to one directory layout and one virtual environment. Python’s documentation describes virtual environments as inherently non-portable because their installed scripts contain absolute interpreter paths. Moving or sharing the project requires recreating the environment.
An alias hides the source path; it does not solve installation, dependency metadata, or portability. Later in the series, Python packaging will generate the tihttp executable from a declared entry point. That is the durable boundary: users invoke a command while the packaging tool connects it to the correct Python function and environment.
The next contract: input
The most immediate limitation is the hard-coded URL. A command-line HTTP client should receive its target from the caller without requiring a source-code edit.
In part 2, we will introduce that input through sys.argv. Starting with the low-level mechanism is intentional: it reveals how quickly validation and flag handling become their own concern, which motivates the move to argparse in part 3.