With Typer, you can write command line tools in Python, intuitively and easily. At the same time, Typer is flexible enough to handle the complexity thrown at it.
Type hints
Among command line helper libraries for Python (such as argparse and Click), Typer is unique in its use of type hints.
Python is a programming language that does not require static type declarations. In other words, I can write my_variable = 2
without needing to specify that my_variable
is indeed an integer, not a string.
While optional, Python does support static type hints. In other words, I can write my_variable: int = 2
to hint that my_variable
should always be an integer. How does one use this? The tool mypy can be run on the Python modules and packages to ferret out problems, or confirm your awesomeness.
The end result? I am continually impressed how, even on small projects, mypy and type hints identify problems in my code that would have caused significant diagnostic headaches.
If you are writing type hints anyway, using Typer to build a command line interface makes a lot of sense. Let's walk through a simple example of what that could look like.
Create the project and add a module
To manage the project, I am using Poetry. Poetry is a mature and modern tool for managing a Python project and its dependencies. You might enjoy reading my introduction to Poetry as well as a brief explanation of using Poetry to expose command line scripts in your project.
You certainly don't need to use Poetry to construct and maintain a project. I do recommend treating your project as a Python package, and using virtual environments. Feel free to browse my demonstration and tool overview for managing virtual environments. Regarding packaging a Python project the traditional way, using
setup.py
, not Poetry, you might like my Python dev environment intro.
To create the project with Poetry, something like this should work:
poetry new --name greet --src typergreet
cd typergreet
Note that in this case I name the internal package greet
but the project directory is named typergreet
.
You do not need to use the src
structure for your package, but I use it for reasons.
I then add a file called greet.py
in the src/greet
subdirectory, with the following contents:
"""Send greetings."""
import time
import arrow # type: ignore
def greet(tz: str, repeat: int = 1, interval: int = 3) -> None:
"""Parse a timezone and greet a location a number of times."""
for i in range(repeat):
if i > 0: # no delay needed on first round
time.sleep(interval)
now = arrow.now(tz)
friendly_time = now.format("h:mm a")
seconds = now.format("s")
location = tz.split("/")[-1].replace("_", " ")
print(f"Hello, {location}!")
print(f"The time is {friendly_time} and {seconds} seconds.\n")
Note the type hints on each function parameter, as well as the return value. Because there is no return in this function (the shame!) the return type is None
.
Install dependencies
We need arrow, and will be using Typer, so they should be added now:
poetry add arrow typer[all]
That will give us Arrow, plus Typer with all the bells and whistles. If you don't want colorized output and shell detection, you can omit the [all]
.
We are using type hints, so we also want mypy
, but as a development dependency. With poetry add
, that means passing the -D
flag:
poetry add -D mypy
Check the type hints
To check to make sure we have the type hints correct, and the code honors them, run mypy
:
poetry run mypy src/greet/greet.py
No issues found? Excellent.
When importing this package, even when making the tests later, we will want to know that it is type hinted. To do that, create an empty py.typed
file in the package. There are many ways to do that (with your text editor, even). This works in Bash and Powershell:
echo "" | tee ./src/greet/py.typed
Add a script end point in pyproject.toml
To expose the greet
function as a command line script, add a tool.poetry.scripts
section to pyproject.toml
.
[tool.poetry.scripts]
greet = "greet.greet:greet"
That sets greet
(the script) to look in greet
(the package) for greet
(the module) and use greet
(the function). If you have more creativity for naming, go for it.
Now that the script is set up, install the package and script with
poetry install
Now let's run the newly installed script:
$ poetry run greet
Traceback (most recent call last):
File "<string>", line 1, in <module>
TypeError: greet() missing 1 required positional argument: 'tz'
Failure is the path to learning. And we are learning.
Using Typer to parse command line arguments
We need a way to parse command line arguments and pass those as parameters to the function. Typer detects the type hints and figures out command line arguments and options accordingly.
Here is the original function, with Typer added:
"""Send greetings."""
import time
import arrow # type: ignore
import typer
app = typer.Typer()
@app.command()
def greet(tz: str, repeat: int = 1, interval: int = 3) -> None:
"""Parse a timezone and greet a location a number of times."""
for i in range(repeat):
if i > 0: # no delay needed on first round
time.sleep(interval)
now = arrow.now(tz)
friendly_time = now.format("h:mm a")
seconds = now.format("s")
location = tz.split("/")[-1].replace("_", " ")
print(f"Hello, {location}!")
print(f"The time is {friendly_time} and {seconds} seconds.\n")
def run() -> None:
"""Run commands."""
app()
What has changed? We instantiated the Typer app globally with app = typer.Typer()
. That way, we can decorate any function we want to call from the command line, using the @app.command()
decorator.
Then, we needed to add a command runner to execute the Typer app. That is what the run()
function does.
Given that we have changed the script entry point from greet
to run
, this should be clarified in pyproject.toml
. So, the following change is needed:
[tool.poetry.scripts]
greet = "greet.greet:run"
Now run
is the function that the greet
command will point to, not greet
.
With all this in place, we have a working command line interface.
$ poetry run greet --help
Usage: greet [OPTIONS] TZ
Parse a timezone and greet a location a number of times.
Arguments:
TZ [required]
Options:
--repeat INTEGER [default: 1]
--interval INTEGER [default: 3]
--install-completion Install completion for the current shell.
--show-completion Show completion for the current shell, to copy it or
customize the installation.
--help Show this message and exit.
$
$ poetry run greet --repeat 2 --interval 1 Africa/Johannesburg
Hello, Johannesburg!
The time is 1:06 pm and 38 seconds.
Hello, Johannesburg!
The time is 1:06 pm and 39 seconds.
What a polite and informed command line tool we have.
Note the extra features Typer adds by default: the --help
with descriptions detailing the arguments and options, and the ability to add shell completion should the user desire.
Testing Typer interfaces with pytest
Testing command line interfaces can demand a bit of creativity. Thankfully, Typer provides CliRunner
, a command line runner for testing. If you are familiar with Click, it is very similar to Click's CliRunner. (Not surprising, as Click is a dependency of Typer.)
Place the following in the file tests/test_greet.py
from typer.testing import CliRunner
from greet.greet import app
def test_greet_cli():
runner = CliRunner()
result = runner.invoke(app, ["Europe/Madrid"])
assert result.exit_code == 0
assert "Hello, Madrid!" in result.output
Run the above with poetry run pytest
.
Does the test pass?
Happiness.
Alternatives and further reading
Python is a logical choice when building command line tools, so it is no surprise that many options exist for facilitating the process.
- It is possible to parse your own command line arguments through the list provided by
sys.argv
, as we did in the previously mentioned Poetry/CLI article. - However, the Python Standard Library's own argparse provides much better functionality, simplicity, and efficiency. I wrote an article very similar to this one about using Poetry, argparse, and pytest.
- I have also written brief introductions to the following command line libraries:
- I have heard great reviews of Plumbum and Cleo, but have yet to try them
- For an entirely different way of defining command line interfaces, try docopt. Define interfaces in your docstrings. It is not frequently maintained, though, so there is a docopt-ng fork.
Enjoy the command line!
Top comments (0)