Command line interface
So our product owner has come to us with a new feature request. They want us to add a command line interface (CLI) to our application. This should expose the functionality we've already built in the HTTP API in the form of a CLI, allowing developers to interact with the application without needing to touch the HTTP API itself.
Choosing the library
For the purposes of building the CLI, we can build on top of a library which has done most of the heavy lifting for us. In a similar way in which we saw the FastAPI
framework did for us when it came to the API.
There are lots of options to choose from when it comes to selecting the library we want to use for the CLI:
argparse - Python's standard library offering for building CLIs.
Click - A more modern and widely used library for building CLIs.
Place - Another library for parsing the command line.
Typer - A modern CLI library which builds on top of
click
andargparse
.
The typer
library provides a similar API to that of FastAPI
so we're going to use typer
moving forward.
Installing the dependency
We've been around this block before. So lets install the typer
library within our project:
pip install typer
And finally, to pin it within our project dependencies:
pip freeze > requirements.txt
Writing the test first
To begin with we'll need our test folders and files. So lets tee up the correct folder:
mkdir -p tests/integration/interfaces/cli/modules
And lets add the test files we need:
touch tests/integration/interfaces/cli/modules/test_taxes.py tests/integration/interfaces/cli/modules/__init__.py
And now we're ready to start writing the test in the new file tests/integration/interfaces/cli/modules/test_taxes.py
:
from click.testing import Result
from typer.testing import CliRunner
from cli import app
runner = CliRunner()
class TestCalculateIncomeTaxed:
def test_returns_correct_calculation(self):
"""
Given a salary of £10,000
When the `taxes calculate-income-taxes` command is called
Then the correct calculation is returned in the standard output
"""
# Given
salary = 10_000
main_module_name = "taxes"
sub_module_name = "calculate-income-taxes"
cli_runner = CliRunner()
# When
result: Result = cli_runner.invoke(app=app, args=[main_module_name, sub_module_name, str(salary)])
# Then
assert result.exit_code == 0
assert "£0" in result.stdout
In this test we describe how we want to call our CLI with the command:
taxes calculate-income-taxes 10000
We initialize the CliRunner
from the typer
library and provide the main app
instance to be consumed by it.
On line 26, we check that the exit code of the result is 0. This exit code indicates that everything went just fine, consider it to be the happy path. A non-zero exit code normally indicates that something went wrong.
On line 27, we check stdout
which is the stream in which programs write output to the main environment. This is how we can interact to and from the running program via our CLI.
Note that right now, the import on line 4 which brings in the main app
instance will throw an error because we have not yet defined that app
instance or that file.
Creating the CLI application instance
We'll need to create the object which represents the CLI application.
Lets make a file at the root level of our project. We'll put it at the root level of the codebase primarily so we can get to it easily when issuing commands from the terminal:
touch cli.py
And within that file we should instantiate the CLI application:
from typer import Typer
app = Typer()
if __name__ == "__main__":
app()
Take note of line 6, which means only execute the code block below if the file, in this case cli.py
is being called directly. If it is being imported then only execute the code outside of this block.
This allows us to define what should happen if we call the file and differentiate this from when other files simply import code from within this file.
Implement the solution
Now that we have the main CLI application ready, we are in a position to wire up the new component into this application object. This component will represent a kind of route but for the CLI application instead.
So lets tee up the files that we need:
touch interfaces/cli/modules/taxes.py interfaces/cli/modules/__init__.py interfaces/cli/__init__.py
And now lets drop into the new interfaces/cli/modules/taxes.py
file:
from typer import Typer
from domain.taxes import calculate_income_tax_owed
app = Typer()
@app.command()
def calculate_income_taxes(salary: float) -> None:
calculated_tax: float = calculate_income_tax_owed(salary=salary)
print(f"£{calculated_tax}")
On line 1, we import the Typer
class from the typer
library and instantiate it on line 5. If this feels like deja vu and looks familiar then your instincts would be correct. The API to the typer
library is very similar to that of FastAPI
.
On line 9, we define a new function and decorate it with app.command()
. The typer
library consumes these functions in the same way FastAPI
does, the parameters we define are used as the arguments to the CLI command along with associated type hints.
On line 10, we adopt the approach we've already agreed on in terms of taking the input and passing it to the domain layer for the calculation to be performed.
And finally on line 11, we print the result to the terminal. Remember that we are building a CLI here, so we do want our results to be printed to stdout
. Also note that we are simply printing to stdout
currently. The typer
library comes bundled with rich
, which allows us to add formatting and styling to the content which is published to stdout
, including markup, tables and colours.
Wiring the route up to the CLI app
And now the last thing for us to do is to wire up our taxes
command into the CLI application.
from typer import Typer
from interfaces.cli.modules import taxes
app = Typer()
app.add_typer(typer_instance=taxes.app, name="taxes")
if __name__ == "__main__":
app()
On line 2 we import our taxes
module into the main cli.py
file.
On line 5 we wire up that Typer
instance into the main Typer
CLI application instance.
The add_typer()
method is similar to the register_endpoint()
method we saw when wiring Router
objects up to the FastAPI
application instance.
The name
parameter to the add_typer()
method represents the main module name of the command.
Using the CLI
In the same way in which we tested the application server manually in a previous section. We're now going to do the same with the CLI we've just built.
So lets drop into our terminal:

Entering this into our terminal will submit the command to our CLI and return the calculation:

And that is it. We've now built an application server along with a corresponding CLI. Both of which are calculated with the same domain core logic.
References
Last updated