Skip to main content
Python · lesson 21 of 34
Python beginner Lesson 21 of 34

Building a Python CLI Tool

Build a weather command-line tool with argparse, split CLI and API code, package it with pyproject.toml, and test it offline with unittest.mock.

By SkillByExample · Updated

In this project you build a command-line tool that prints the current temperature for a city, then package it as a real weather command and test it without a network connection. Output below is from Python 3.13.

Project Structure

Keeping all source files under a package directory (weather/) separates importable code from project files such as pyproject.toml, and makes the project installable, which is what turns the script into a command.

weather-cli/
├── pyproject.toml       # packaging metadata and the console-script entry point
├── weather/
│   ├── __init__.py      # marks the directory as a package (can be empty)
│   ├── api.py           # HTTP logic only
│   └── main.py          # argument parsing and entry point only
└── tests/
    ├── __init__.py
    └── test_main.py

The API Layer

api.py talks to the free, keyless Open-Meteo API using only the standard library: one request turns the city name into coordinates, a second fetches the current temperature.

# weather/api.py
import json
import urllib.parse
import urllib.request

GEO_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"

def _get_json(url: str, params: dict) -> dict:
    with urllib.request.urlopen(f"{url}?{urllib.parse.urlencode(params)}", timeout=10) as resp:
        return json.load(resp)

def fetch_weather(city: str, metric: bool = False) -> dict:
    """Return {'temp': float, 'unit': str} for the current temperature in city."""
    places = _get_json(GEO_URL, {"name": city, "count": 1}).get("results")
    if not places:
        raise ValueError(f"unknown city {city!r}")
    unit = "celsius" if metric else "fahrenheit"
    forecast = _get_json(FORECAST_URL, {
        "latitude": places[0]["latitude"],
        "longitude": places[0]["longitude"],
        "current": "temperature_2m",
        "temperature_unit": unit,
    })
    return {"temp": forecast["current"]["temperature_2m"], "unit": unit[0].upper()}

All network access goes through _get_json, so there is exactly one function to replace when testing. A missing city becomes a ValueError with a readable message instead of a KeyError deep in the JSON.

Writing the CLI Logic

argparse is Python’s built-in library for command-line interfaces. You declare the arguments; it parses them, converts types, and generates --help and usage errors.

# weather/main.py
import argparse
import sys

from weather.api import fetch_weather

def main(argv=None):
    parser = argparse.ArgumentParser(prog="weather", description="Fetch current weather details.")
    parser.add_argument("city", help="Name of the city to query")
    parser.add_argument("--metric", action="store_true",   # True if present, False if absent
                        help="Use Celsius instead of Fahrenheit")
    args = parser.parse_args(argv)   # argv=None means "read sys.argv"

    try:
        data = fetch_weather(args.city, args.metric)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)   # errors go to stderr, not stdout
        return 1                                # non-zero exit code signals failure
    print(f"Weather in {args.city.title()}: {data['temp']}°{data['unit']}")
    return 0

if __name__ == "__main__":
    sys.exit(main())
$ python -m weather.main --help
usage: weather [-h] [--metric] city

Fetch current weather details.

positional arguments:
  city        Name of the city to query

options:
  -h, --help  show this help message and exit
  --metric    Use Celsius instead of Fahrenheit

That help screen came entirely from the description and help= strings. Leave out the required city and parse_args() exits before the rest of main runs:

$ python -m weather.main; echo "exit code: $?"
usage: weather [-h] [--metric] city
weather: error: the following arguments are required: city
exit code: 2

Exit code 2 is the Unix convention for “bad usage”, distinct from the 1 that main returns when the lookup itself fails. main(argv=None) reads the real command line by default, but a test can pass a list instead.

Packaging the Application

pyproject.toml tells the installer the project’s name and version, and [project.scripts] maps a command name to the function it should call.

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

[project]
name = "weather-cli"
version = "0.1.0"
requires-python = ">=3.10"

[project.scripts]
weather = "weather.main:main"   # command-name = "package.module:function"

[tool.setuptools]
packages = ["weather"]

After pip install -e . from the project root, weather is an ordinary command:

$ weather --help | head -1
usage: weather [-h] [--metric] city
$ weather london; echo "exit code: $?"
Error: <urlopen error Tunnel connection failed: 403 Forbidden>
exit code: 1

Editable mode (-e) points the install at your source folder, so later edits take effect without reinstalling. The second command shows the error path for real: this run was made from a machine with no route to the API, so the exception was printed to stderr and the generated wrapper turned main’s return 1 into the process exit code. On a connected machine the same command prints Weather in London: followed by the current temperature in °F.

Testing Without the Network

Because main only calls fetch_weather, a test can swap that one name for a fake and check the CLI’s behaviour in isolation.

# tests/test_main.py
import unittest
from unittest.mock import patch

from weather.main import main

class MainTests(unittest.TestCase):
    @patch("weather.main.fetch_weather", return_value={"temp": 21.5, "unit": "C"})
    def test_prints_temperature(self, fake_fetch):
        with patch("builtins.print") as fake_print:
            code = main(["new york", "--metric"])
        self.assertEqual(code, 0)
        fake_fetch.assert_called_once_with("new york", True)
        fake_print.assert_called_once_with("Weather in New York: 21.5°C")

    @patch("weather.main.fetch_weather", side_effect=ValueError("unknown city 'atlantis'"))
    def test_error_exit_code(self, fake_fetch):
        with patch("sys.stderr"):
            self.assertEqual(main(["atlantis"]), 1)

if __name__ == "__main__":
    unittest.main()
$ python -m unittest tests.test_main -v
test_error_exit_code (tests.test_main.MainTests.test_error_exit_code) ... ok
test_prints_temperature (tests.test_main.MainTests.test_prints_temperature) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK

patch("weather.main.fetch_weather", ...) replaces the name where main.py looks it up, not in api.py. The first test checks both directions: the arguments main passed down ("new york", True) and the exact line it printed. Neither test opened a socket, which is why they run in milliseconds.

Practice

1. Add a --version flag and an integer --retries option to main.py. What does argparse do with --retries three?

Solution

Add two lines after the --metric argument:

    parser.add_argument("--version", action="version", version="%(prog)s 0.1.0")
    parser.add_argument("--retries", type=int, default=1, help="Attempts before giving up")
$ weather --version
weather 0.1.0
$ weather london --retries three; echo "exit code: $?"
usage: weather [-h] [--metric] [--version] [--retries RETRIES] city
weather: error: argument --retries: invalid int value: 'three'
exit code: 2

action="version" prints and exits without needing city. type=int is applied while parsing, so a bad value is rejected with a usage error and exit code 2 before your code runs.

2. Write a test that proves error messages go to stderr and nothing goes to stdout.

Solution
# tests/test_stderr.py
import io
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import patch

from weather.main import main

class StreamTests(unittest.TestCase):
    @patch("weather.main.fetch_weather", side_effect=ValueError("unknown city 'atlantis'"))
    def test_error_goes_to_stderr(self, fake_fetch):
        out, err = io.StringIO(), io.StringIO()
        with redirect_stdout(out), redirect_stderr(err):
            code = main(["atlantis"])
        self.assertEqual(code, 1)
        self.assertEqual(out.getvalue(), "")
        self.assertEqual(err.getvalue(), "Error: unknown city 'atlantis'\n")

if __name__ == "__main__":
    unittest.main()
$ python -m unittest tests.test_stderr -v
test_error_goes_to_stderr (tests.test_stderr.StreamTests.test_error_goes_to_stderr) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

redirect_stdout and redirect_stderr capture the two streams separately, so the test can assert that stdout stayed empty. That matters for a CLI whose output might be piped into another program.

3. Test fetch_weather itself without the network by patching _get_json, and check that metric=True requests Celsius.

Solution
# tests/test_api.py
import unittest
from unittest.mock import patch

from weather import api

class ApiTests(unittest.TestCase):
    def test_metric_request(self):
        responses = [
            {"results": [{"latitude": 51.5, "longitude": -0.13}]},  # geocoding reply
            {"current": {"temperature_2m": 12.3}},                    # forecast reply
        ]
        with patch("weather.api._get_json", side_effect=responses) as fake:
            self.assertEqual(api.fetch_weather("London", metric=True), {"temp": 12.3, "unit": "C"})
        self.assertEqual(fake.call_args.args[1]["temperature_unit"], "celsius")

    def test_unknown_city(self):
        with patch("weather.api._get_json", return_value={}):
            with self.assertRaises(ValueError):
                api.fetch_weather("Atlantis")

if __name__ == "__main__":
    unittest.main()
$ python -m unittest tests.test_api -v
test_metric_request (tests.test_api.ApiTests.test_metric_request) ... ok
test_unknown_city (tests.test_api.ApiTests.test_unknown_city) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

side_effect with a list returns one item per call, in order: first the geocoding reply, then the forecast. fake.call_args records the last call, so the test can check the parameters fetch_weather sent without any request leaving the machine.

Frequently Asked Questions

Should I use setup.py or pyproject.toml for a new Python project?
Use pyproject.toml. It is the standard, tool-independent place for project metadata and build configuration, and setuptools, pip and uv all read it. A setup.py file is only needed for unusual builds that must run custom Python code at install time.
How do I turn a Python script into a command I can run from anywhere?
Declare it under [project.scripts] in pyproject.toml, for example weather = "weather.main:main", then install the project with pip install -e . from its root. The installer creates a small executable on your PATH that imports the module and calls that function.
Why should a CLI's main() accept an argv parameter?
argparse reads sys.argv when parse_args() gets None, so main(argv=None) behaves normally from the shell. Passing a list instead lets tests call main(["london", "--metric"]) directly, with no subprocess and no patching of sys.argv.
How do I test code that calls a web API without hitting the network?
Isolate the HTTP call in one small function and replace it in tests with unittest.mock.patch, returning canned data or raising an error. The tests then run in milliseconds, work offline and cannot fail because a remote service is down.