//

Let's build a modern CMD tool with Python using Typer and Rich

14.10.2022 | 10 minutes of reading time

Let's build a modern CMD tool with Python using Typer and Rich

I often have a need for a small CMD tool for my projects - e.g. to query an API or perform some operation. What do I want from the tool?

  • Quick development cycle
  • Nice output, e.g. with syntax Highlighting for JSON or Tables.
  • Easy to install for all people in the project
  • Some documentation so I know what it does a few days later.

Most projects I've seen use Bash for that, but that has a couple of drawbacks:

  • Its not portable, e.g. if you have windows users in your team they will have a harder time to run that
  • It does not really grow if your tool becomes more complex
  • Not that many people can write good bash scripts
  • It's output is usually ugly

Good alternatives are, at least for me, Powershell and Python. While Powershell is powerful and portable, its less well known than Python so I will focus on Python here.

As an example, I will walk through how to create such a tool by using the Star Wars API and show how to build a nice client for that.

How to install the Python environment?

While there are lots of tools to install Python nowadays, I use Poetry for my projects. It's easy to use and usually just does it's job.

Please install it and see the documentation above. You will also need a modernish Python Version to follow along. I use Python 3.10 but everything from 3.5 should work.

Setup

First create a new Project using Poetry

1mkdir swapi
2cd swapi
3poetry init
4

Just answer the questions poetry asks you (pressing enter is enough for this tutorial) and choose no if it asks for interactice dependencies.

For me it looks like this:

1poetry init
2
3This command will guide you through creating your pyproject.toml config.
4
5Package name [swapi]:
6Version [0.1.0]:
7Description []:
8Author [christian.sauer <christian.sauer@codecentric.de>, n to skip]:
9License []:
10Compatible Python versions [^3.10]:
11
12Would you like to define your development dependencies interactively? (yes/no) [yes] n
13Generated file
14
15[tool.poetry]
16name = "swapi"
17version = "0.1.0"
18description = ""
19authors = ["christian.sauer <christian.sauer@codecentric.de>"]
20
21[tool.poetry.dependencies]
22python = "^3.10"
23
24[tool.poetry.dev-dependencies]
25
26[build-system]
27requires = ["poetry-core>=1.0.0"]
28build-backend = "poetry.core.masonry.api"
29
30
31Do you confirm generation? (yes/no) [yes] y
32

Now we need just two dependencies: typer and httpx. Typer is a package designed to build CMD tools fast by leveraging several modern Python features like type hinting. It also installs the rich package which is very good at displaying gorgeous output. HTTPX is a REST client for python.

1poetry add "typer[all]"
2poetry add httpx
3
1Creating virtualenv swapi in ****
2Using version ^0.6.1 for typer
3
4Updating dependencies
5Resolving dependencies...
6
7Writing lock file
8
9Package operations: 7 installs, 0 updates, 0 removals
10
11  • Installing colorama (0.4.5)
12  • Installing commonmark (0.9.1)
13  • Installing pygments (2.13.0)
14  • Installing click (8.1.3)
15  • Installing rich (12.5.1)
16  • Installing shellingham (1.5.0)
17  • Installing typer (0.6.1)
18

Please create now a file called api.py and open it in your favorite editor.

We need some boilerplate, but it's not much:

1import typer
2
3app = typer.Typer()
4
5if __name__ == "__main__":
6    app()
7

This does not actually do anything but it's the minimum to get started.

First task: Lets query the API for a list of all vehicles

I want to get a list of all known Star Wars vehicles and their passenger capacity.

For that we have to query this URL: https://swapi.dev/api/vehicles/

Lets add a command for that:

1"""
2Star Wars API Client
3"""
4import httpx
5import typer
6
7app = typer.Typer()
8
9
10@app.command()
11def get_vehicles():
12    """
13    Get the list of vehicles
14    """
15    response = httpx.get('https://swapi.dev/api/vehicles/')
16    response.raise_for_status() # throw if something happened
17    print(response.json())
18
19
20@app.command()
21def hello():
22    """
23    Dummy command used to force typer to create subcommands directly.
24    If only one command is present typer will not create sub commands. Just ignore it for now.
25    """
26    print("Hello")
27
28
29if __name__ == "__main__":
30    app()
31

Now we can start using our client:

1poetry run python api.py --help
2
3 Usage: api.py [OPTIONS] COMMAND [ARGS]...
4
5╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
6│ --install-completion          Install completion for the current shell.                                                                                         │
7│ --show-completion             Show completion for the current shell, to copy it or customize the installation.                                                  │
8│ --help                        Show this message and exit.                                                                                                       │
9╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
10╭─ Commands ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
11│ get-vehicles  Get the list of vehicles                                                                                                                          │
12│ hello         Dummy command used to force typer to create subcommands directly. If only one command is present typer will not create sub commands. Just ignore  │
13│               it for now.                                                                                                                                       │
14╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
15

If you look at the help you will notice that typer automatically created the help content for us, using the descriptions of the commands. It also transforms the function names into commands, e.g. get_vehicles is turned into get-vehicles.

Lets get the list of vehicles:

1poetry run python api.py get-vehicles
2{'count': 39, 'next': 'https://swapi.dev/api/vehicles/?page=2', 'previous': None, 'results': [{'name': 'Sand Crawler', 'model': 'Digger Crawler', 'manufacturer': 'Corellia Mining Corporation', 'cost_in_credits': '150000', 'length': '36.8 ', 'max_atmosphering_speed': '30', 'crew': '46', 'passengers': '30', 'cargo_capacity': '50000', 'consumables': '2 months', 'vehicle_class': 'wheeled', 'pilots': [], 'films': ['https://swapi.dev/api/films/1/', 'https://swapi.dev/api/films/5/'], 'created': '2014-12-10T15:36:25.724000Z', 'edited': '2014-12-20T21:30:21.661000Z', 'url': 'https://swapi.dev/api/vehicles/4/'}, {'name': 'T-16 skyhopper', 'model': 'T-16 skyhopper', 'manufacturer': 'Incom Corporation', 'cost_in_credits': '14500', 'length': '10.4 ', 'max_atmosphering_speed': '1200', 'crew': '1', 'passengers': '1', 'cargo_capacity': '50', 'consumables': '0', 'vehicle_class': 'repulsorcraft', 'pilots': [], 'films': ['https://swapi.dev/api/films/1/'], 'created': '2014-12-10T16:01:52.434000Z', 'edited': '2014-12-20T21:30:21.665000Z', 'url': 'https://swapi.dev/api/vehicles/6/'}, {'name': 'X-34 landspeeder', 'model': 'X-34 landspeeder', 'manufacturer': 'SoroSuub Corporation', 'cost_in_credits': '10550', 'length': '3.4 ', 'max_atmosphering_speed': '250', 'crew': '1', 'passengers': '1', 'cargo_capacity': '5', 'consumables': 'unknown', 'vehicle_class': 'repulsorcraft', 'pilots': [], 'films': ['https://swapi.dev/api/films/1/'], 'created': '2014-12-10T16:13:52.586000Z', 'edited': '2014-12-20T21:30:21.668000Z', 'url': 'https://swapi.dev/api/vehicles/7/'}, {'name': 'TIE/LN starfighter', 'model': 'Twin Ion Engine/Ln Starfighter', 'manufacturer': 'Sienar Fleet Systems', 'cost_in_credits': 'unknown', 'length': '6.4', 'max_atmosphering_speed': '1200', 'crew': '1', 'passengers': '0', 'cargo_capacity': '65', 'consumables': '2 days', 'vehicle_class': 'starfighter', 'pilots': [], 'films': ['https://swapi.dev/api/films/1/', 'https://swapi.dev/api/films/2/', 'https://swapi.dev/api/films/3/'], 'created': '2014-12-10T16:33:52.860000Z', 'edited': '2014-12-20T21:30:21.670000Z', 'url': 'https://swapi.dev/api/vehicles/8/'}, {'name': 'Snowspeeder', 'model': 't-47 airspeeder', 'manufacturer': 'Incom corporation', 'cost_in_credits': 'unknown', 'length': '4.5', 'max_atmosphering_speed': '650', 'crew': '2', 'passengers': '0', 'cargo_capacity': '10', 'consumables': 'none', 'vehicle_class': 'airspeeder', 'pilots': ['https://swapi.dev/api/people/1/', 'https://swapi.dev/api/people/18/'], 'films': ['https://swapi.dev/api/films/2/'], 'created': '2014-12-15T12:22:12Z', 'edited': '2014-12-20T21:30:21.672000Z', 'url': 'https://swapi.dev/api/vehicles/14/'}, {'name': 'TIE bomber', 'model': 'TIE/sa bomber', 'manufacturer': 'Sienar Fleet Systems', 'cost_in_credits': 'unknown', 'length': '7.8', 'max_atmosphering_speed': '850', 'crew': '1', 'passengers': '0', 'cargo_capacity': 'none', 'consumables': '2 days', 'vehicle_class': 'space/planetary bomber', 'pilots': [], 'films': ['https://swapi.dev/api/films/2/', 'https://swapi.dev/api/films/3/'], 'created': '2014-12-15T12:33:15.838000Z', 'edited': '2014-12-20T21:30:21.675000Z', 'url': 'https://swapi.dev/api/vehicles/16/'}, {'name': 'AT-AT', 'model': 'All Terrain Armored Transport', 'manufacturer': 'Kuat Drive Yards, Imperial Department of Military Research', 'cost_in_credits': 'unknown', 'length': '20', 'max_atmosphering_speed': '60', 'crew': '5', 'passengers': '40', 'cargo_capacity': '1000', 'consumables': 'unknown', 'vehicle_class': 'assault walker', 'pilots': [], 'films': ['https://swapi.dev/api/films/2/', 'https://swapi.dev/api/films/3/'], 'created': '2014-12-15T12:38:25.937000Z', 'edited': '2014-12-20T21:30:21.677000Z', 'url': 'https://swapi.dev/api/vehicles/18/'}, {'name': 'AT-ST', 'model': 'All Terrain Scout Transport', 'manufacturer': 'Kuat Drive Yards, Imperial Department of Military Research', 'cost_in_credits': 'unknown', 'length': '2', 'max_atmosphering_speed': '90', 'crew': '2', 'passengers': '0', 'cargo_capacity': '200', 'consumables': 'none', 'vehicle_class': 'walker', 'pilots': ['https://swapi.dev/api/people/13/'], 'films': ['https://swapi.dev/api/films/2/', 'https://swapi.dev/api/films/3/'], 'created': '2014-12-15T12:46:42.384000Z', 'edited': '2014-12-20T21:30:21.679000Z', 'url': 'https://swapi.dev/api/vehicles/19/'}, {'name': 'Storm IV Twin-Pod cloud car', 'model': 'Storm IV Twin-Pod', 'manufacturer': 'Bespin Motors', 'cost_in_credits': '75000', 'length': '7', 'max_atmosphering_speed': '1500', 'crew': '2', 'passengers': '0', 'cargo_capacity': '10', 'consumables': '1 day', 'vehicle_class': 'repulsorcraft', 'pilots': [], 'films': ['https://swapi.dev/api/films/2/'], 'created': '2014-12-15T12:58:50.530000Z', 'edited': '2014-12-20T21:30:21.681000Z', 'url': 'https://swapi.dev/api/vehicles/20/'}, {'name': 'Sail barge', 'model': 'Modified Luxury Sail Barge', 'manufacturer': 'Ubrikkian Industries Custom Vehicle Division', 'cost_in_credits': '285000', 'length': '30', 'max_atmosphering_speed': '100', 'crew': '26', 'passengers': '500', 'cargo_capacity': '2000000', 'consumables': 'Live food tanks', 'vehicle_class': 'sail barge', 'pilots': [], 'films': ['https://swapi.dev/api/films/3/'], 'created': '2014-12-18T10:44:14.217000Z', 'edited': '2014-12-20T21:30:21.684000Z', 'url': 'https://swapi.dev/api/vehicles/24/'}]}
3

While this works its not readable at all and would need serious post processing. Luckily rich can auto-format JSON using its very versatile print method:

1"""
2Star Wars API Client
3"""
4import httpx
5import typer
6from rich import print # this is new
7app = typer.Typer()
8
9
10@app.command()
11def get_vehicles():
12    """
13    Get the list of vehicles
14    """
15    response = httpx.get('https://swapi.dev/api/vehicles//')
16    response.raise_for_status() # throw if something happened
17    print(response.json())
18
19
20@app.command()
21def hello():
22    """
23    Dummy command used to force typer to create subcommands directly.
24    If only one command is present typer will not create sub commands. Just ignore it for now.
25    """
26    print("Hello")
27
28
29if __name__ == "__main__":
30    app()
31

The output is now much more readable:

1poetry run python api.py get-vehicles
2{
3    'count': 39,
4    'next': 'https://swapi.dev/api/vehicles/?page=2',
5    'previous': None,
6    'results': [
7        {
8            'name': 'Sand Crawler',
9            'model': 'Digger Crawler',
10            'manufacturer': 'Corellia Mining Corporation',
11            'cost_in_credits': '150000',
12            'length': '36.8 ',
13            'max_atmosphering_speed': '30',
14            'crew': '46',
15            'passengers': '30',
16            'cargo_capacity': '50000',
17            'consumables': '2 months',
18            'vehicle_class': 'wheeled',
19            'pilots': [],
20            'films': ['https://swapi.dev/api/films/1/', 'https://swapi.dev/api/films/5/'],
21            'created': '2014-12-10T15:36:25.724000Z',
22            'edited': '2014-12-20T21:30:21.661000Z',
23            'url': 'https://swapi.dev/api/vehicles/4/'
24        },
25        ... omitted for brevity
26    ]
27}
28

Thats much better! But we can do even more using rich: We can easily output a table just showing the relevant information.

1"""
2Star Wars API Client
3"""
4import httpx
5import typer
6from rich import print
7from rich.table import Table
8from rich.console import Console
9
10app = typer.Typer()
11console = Console()
12
13
14@app.command()
15def get_vehicles():
16    """
17    Get the list of vehicles
18    """
19    response = httpx.get('https://swapi.dev/api/vehicles//')
20    response.raise_for_status() # throw if something happened
21    table = Table(show_footer=False, title=f"Star Wars Vehicles", title_justify="left")
22
23    table.add_column("Name")
24    table.add_column("Model")
25    table.add_column("Passengers")
26
27    for entry in response.json()["results"]:
28        table.add_row(entry['name'], entry['model'], entry['passengers'])
29
30    console.clear()
31    console.print(table)
32
33
34@app.command()
35def hello():
36    """
37    Dummy command used to force typer to create subcommands directly.
38    If only one command is present typer will not create sub commands. Just ignore it for now.
39    """
40    print("Hello")
41
42
43if __name__ == "__main__":
44    app()
45

I've added a table using rich, and the output now looks like:

1poetry run python api.py get-vehicles
2Star Wars Vehicles
3┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
4┃ Name                        ┃ Model                          ┃ Passengers ┃
5┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
6│ Sand Crawler                │ Digger Crawler                 │ 30         │
7│ T-16 skyhopper              │ T-16 skyhopper                 │ 1          │
8│ X-34 landspeeder            │ X-34 landspeeder               │ 1          │
9│ TIE/LN starfighter          │ Twin Ion Engine/Ln Starfighter │ 0          │
10│ Snowspeeder                 │ t-47 airspeeder                │ 0          │
11│ TIE bomber                  │ TIE/sa bomber                  │ 0          │
12│ AT-AT                       │ All Terrain Armored Transport  │ 40         │
13│ AT-ST                       │ All Terrain Scout Transport    │ 0          │
14│ Storm IV Twin-Pod cloud car │ Storm IV Twin-Pod              │ 0          │
15│ Sail barge                  │ Modified Luxury Sail Barge     │ 500        │
16└─────────────────────────────┴────────────────────────────────┴────────────┘
17

Thats much easier to parse and understand. Rich also supports much more advanced output, e.g. progress bars, diagrams and much more. While I seldom use all of it in a project, its advanced capabilities are really helpful to make the output easier to understand. As an example: For a project of mine I query an API every 5 Seconds and watch for changes in the state. If there is a change, I simply output the field in red. While that does not sound like much, it's super helpful if you have ~50 fields and need to look for changes.

Second task: Add some commands

While showing of rich is super easy, you should not dismiss typish at all - while its ability to create help output is valuable it really shines if you need to accept input from the command line. It can validate the input against the type given in the function signature, eliminating the need for lots of boiler plate code. Lets add a quick example: We want to add the option to filter by the film (Valid options 1,2,3) and the number of passengers (any positive integer is valid)

1"""
2Star Wars API Client
3"""
4import httpx
5import typer
6from rich import print
7from rich.table import Table
8from rich.console import Console
9
10app = typer.Typer()
11console = Console()
12
13
14@app.command()
15def get_vehicles(passengers: int = typer.Argument(0, help="Filters by the number of passengers"),
16                 film: int = typer.Argument(0, min=0, max=9, help="If not 0, vehicles not in the given film are filtered")):
17    """
18    Get the list of vehicles
19    """
20    response = httpx.get('https://swapi.dev/api/vehicles//')
21    response.raise_for_status() # throw if something happened
22    table = Table(show_footer=False, title=f"Star Wars Vehicles", title_justify="left")
23
24    table.add_column("Name")
25    table.add_column("Model")
26    table.add_column("Passengers")
27    table.add_column("Films")
28
29    for entry in response.json()["results"]:
30        if int(entry["passengers"]) < passengers:
31            continue
32
33        films = [x[-2] for x in entry['films']] # is an array like ['https://swapi.dev/api/films/2/', 'https://swapi.dev/api/films/3/']
34        if film != 0:  # 0 == all films
35            if str(film) not in films:
36                continue
37
38        table.add_row(entry['name'], entry['model'], entry['passengers'], ", ".join(films))
39
40    console.clear()
41    console.print(table)
42
43
44@app.command()
45def hello():
46    """
47    Dummy command used to force typer to create subcommands directly.
48    If only one command is present typer will not create sub commands. Just ignore it for now.
49    """
50    print("Hello")
51
52
53if __name__ == "__main__":
54    app()
55

Ok how we can use this? Typer autogenerates a help for us

1 poetry run python api.py get-vehicles --help
2
3 Usage: api.py get-vehicles [OPTIONS] [PASSENGERS] [FILM]
4
5 Get the list of vehicles
6
7╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
8│   passengers      [PASSENGERS]  Filters by the number of passengers [default: 0]                                                                                │
9│   film            [FILM]        If not 0, vehicles not in the given film are filtered [default: 0]                                                              │
10╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
11╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
12│ --help          Show this message and exit.                                                                                                                     │
13╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
14

Lets try it:

All passengers but only film 5:

1poetry run python api.py get-vehicles 0 5
2Star Wars Vehicles
3┏━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━┓
4┃ Name         ┃ Model          ┃ Passengers ┃ Films ┃
5┡━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━┩
6│ Sand Crawler │ Digger Crawler │ 30         │ 1, 5  │
7└──────────────┴────────────────┴────────────┴───────┘
8

More than 10 passengers and all films:

1poetry run python api.py get-vehicles 10
2Star Wars Vehicles
3┏━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━┓
4┃ Name         ┃ Model                         ┃ Passengers ┃ Films ┃
5┡━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━┩
6│ Sand Crawler │ Digger Crawler                │ 30         │ 1, 5  │
7│ AT-AT        │ All Terrain Armored Transport │ 40         │ 2, 3  │
8│ Sail barge   │ Modified Luxury Sail Barge    │ 500        │ 3     │
9└──────────────┴───────────────────────────────┴────────────┴───────┘
10

More than 10 passengers and film 3:

1poetry run python api.py get-vehicles 10 3
2Star Wars Vehicles
3┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━┓
4┃ Name       ┃ Model                         ┃ Passengers ┃ Films ┃
5┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━┩
6│ AT-AT      │ All Terrain Armored Transport │ 40         │ 2, 3  │
7│ Sail barge │ Modified Luxury Sail Barge    │ 500        │ 3     │
8└────────────┴───────────────────────────────┴────────────┴───────┘
9

Conclusion

This quick tutorial only scratched the surface of what's possible with Typer and Rich. Both tools together really simplify the creation of useful CMD tools. Since I discovered them, I build more helper tools for my projects than before - simply because they are easier to built and maintain than ever before.

share post

Likes

4

//

More articles in this subject area\n

Discover exciting further topics and let the codecentric world inspire you.

//

Gemeinsam bessere Projekte umsetzen

Wir helfen Deinem Unternehmen

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.