Python · lesson 21 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Building 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.