Как написать консольное приложение на python

Python — невероятно гибкий язык программирования, который хорошо интегрируется с существующими программами. Немало Python-кода написано в виде скриптов и интерфейсов командной строки (CLI).

Инструменты и интерфейсы командной строки — эффективная вещь, так как они позволяют автоматизировать практически всё что угодно. Как следствие, эти интерфейсы с течением времени могут стать довольно сложными.

Обычно всё начинается с простого скрипта на Python, который что-то делает. Например, получает доступ к веб-API и выводит результат в консоль:

# print_user_agent.py
import requests

json = requests.get('http://httpbin.org/user-agent').json()
print(json['user-agent'])

Вы можете запустить этот скрипт с помощью команды python3 print_user_agent.py, и он выведет имя user-agent, использованного для вызова API.

Как и было сказано, довольно простой скрипт.

Но что делать, когда подобная программа растёт и становится всё более сложной?

Решением этого вопроса мы сегодня и займёмся. Вы узнаете об основах написания интерфейсов командной строки на Python и о том, как click позволяет упростить этот процесс.

Используя эти знания, мы шаг за шагом перейдём от простого скрипта к интерфейсу командной строки с аргументами, опциями и полезными инструкциями по использованию. Всё это мы сделаем с помощью библиотеки click.

К концу этой статьи вы будете знать:

  • Почему click — лучшая альтернатива argparse и optparse;
  • Как с его помощью создать простой CLI;
  • Как добавить обязательные аргументы командной строки в ваши скрипты;
  • Как парсить флаги и опции командной строки;
  • Как сделать ваши консольные приложения более удобными, добавив справочный текст.

Вы увидите, как сделать всё это с минимальным количеством шаблонного кода.

Примечание переводчика Код в данной статье написан на Python 3.6, работоспособность на более ранних версиях не гарантируется.

Итак, начнём!

Код выше — всего лишь пример, не очень полезный в реальной жизни. На самом деле скрипты бывают куда более сложные. Возможно, вы имели опыт с ними и знаете, что они могут быть важной частью нашей повседневной работы: некоторые скрипты остаются на протяжении всего времени жизни проекта, для которого они были написаны. Некоторые начинают приносить пользу другим командам или проектам. У них даже может расширяться функционал.

В этих случаях важно сделать скрипты более гибкими и настраиваемыми с помощью параметров командной строки. Они позволяют указать имя сервера, учётные данные или любую другую информацию скрипту.

Здесь приходят на выручку такие модули, как optparse и argparse, которые делают нашу жизнь на порядок проще. Но прежде чем мы с ними познакомимся, давайте разберёмся с терминологией.

Основы интерфейса командной строки

Интерфейс командной строки (CLI) начинается с имени исполняемого файла. Вы вводите имя в консоль и получаете доступ к главной точке входа скрипта, такого как pip.

В зависимости от сложности CLI обычно есть определённые параметры, которые вы можете передавать скрипту:

  1. Аргумент, который является обязательным параметром. Если его не передать, то CLI вернёт ошибку. Например, в следующей команде click является аргументом: pip install click.
  2. Опция — необязательный параметр, который объединяет имя и значение, например --cache-dir ./my-cache. Вы говорите CLI, что значение ./my-cache должно использоваться как директория для кэша.
  3. Флаг, который включает или выключает определённый сценарий. Вероятно, самым частым является --help. Вы только указываете имя, а CLI самостоятельно интерпретирует значение.

С более сложными CLI, такими как pip или Heroku CLI, вы получаете доступ к набору функций, которые собраны под главной точкой входа. Они обычно называются командами или подкомандами.

Возможно, вы уже использовали CLI, когда устанавливали Python-библиотеку с помощью команды pip install <имя пакета>. Команда install говорит CLI, что вы хотите использовать функцию установки пакета, и даёт вам доступ к параметрам, характерным для этой функции.

Пакеты для работы с командной строкой, доступные в стандартной библиотеке Python 3.x

Добавление команд и параметров в ваши скрипты может сделать их значительно лучше, но парсить командную строку не так просто, как может показаться. Однако вместо того, чтобы пытаться самостоятельно решить эту проблему, лучше воспользоваться одним из многих пакетов, которые сделали это за вас.

Два наиболее известных пакета для этого — optparse и argparse. Они являются частью стандартной библиотеки Python и добавлены туда по принципу «всё включено».

По большей части они делают одно и то же и работают схожим образом. Главное отличие заключается в том, что optparse не используется начиная с Python 3.2, и argparse считается стандартом для создания CLI в Python.

Вы можете узнать о них больше в документации Python, но, чтобы иметь представление, как выглядит скрипт с argparse, посмотрите на пример ниже:

import argparse

parser = argparse.ArgumentParser(description='Process some integers.')
parser.add_argument('integers', metavar='N', type=int, nargs='+',
                    help='an integer for the accumulator')
parser.add_argument('--sum', dest='accumulate', action='store_const',
                    const=sum, default=max,
                    help='sum the integers (default: find the max)')

args = parser.parse_args()
print(args.accumulate(args.integers))

click против argparse: лучшая альтернатива?

Вероятно, вы смотрите на этот код и думаете: «Что это всё значит?» И это является одной из проблем argparse: код с ним неинтуитивен и сложночитаем.

Поэтому вам может понравиться click.

Click решает ту же проблему, что и optparse и argparse, но немного иначе. Он использует декораторы, поэтому ваши команды должны быть функциями, которые можно обернуть этими декораторами.

С click легко создавать многофункциональный CLI с небольшим количеством кода. И этот код будет легко читаться, даже когда ваш CLI вырастет и станет более сложным.

Пишем простой CLI на Python с помощью click

Вдоволь поговорив о CLI и библиотеках, давайте взглянем на пример, чтобы понять, как написать простой CLI с click. Как и в первом примере, мы создаём простой CLI, который выводит результат в консоль. Это несложно:

# cli.py
import click

@click.command()
def main():
    print("I'm a beautiful CLI ✨")

if __name__ == "__main__":
    main()

Не пугайтесь последних двух строк: это то, как Python запускает функцию main при исполнении файла как скрипта.

Как вы видите, всё, что нам нужно сделать — создать функцию и добавить к ней декоратор @click.command(). Он превращает функцию в команду, которая является главной точкой входа нашего скрипта. Теперь вы можете запустить скрипт через командную строку и увидеть что-то вроде этого:

$ python3 cli.py
I'm a beautiful CLI ✨

Что в click здорово, так это то, что мы получаем некоторые дополнительные возможности просто так. Мы не реализовывали справочную функцию, однако вы можете добавить флаг --help и увидеть базовое сообщение:

$ python3 cli.py --help
Usage: cli.py [OPTIONS]

Options:
  --help  Show this message and exit.

Более реалистичный пример CLI на Python с использованием click

Теперь, когда вы знаете, как click упрощает написание CLI, давайте взглянем на более реалистичный пример. Мы напишем программу, которая позволяет нам взаимодействовать с веб-API.

API, который мы дальше будем использовать, — OpenWeatherMap API. Он предоставляет информацию о текущей погоде, а также прогноз на пять дней для определённого местоположения. Мы начнём с тестового API, который возвращает текущую погоду для места.

Прежде чем мы начнём писать код, давайте познакомимся с API. Для этого можно использовать сервис HTTPie, включая онлайн-терминал.

Давайте посмотрим, что случится, когда мы обратимся к API с Лондоном в качестве местоположения:

$ http --body GET http://samples.openweathermap.org/data/2.5/weather 
  q==London 
  appid==b1b15e88fa797225412429c1c50c122a1
{
    "base": "stations",
    "clouds": {
        "all": 90
    },
    "cod": 200,
    "coord": {
        "lat": 51.51,
        "lon": -0.13
    },
    "dt": 1485789600,
    "id": 2643743,
    "main": {
        "humidity": 81,
        "pressure": 1012,
        "temp": 280.32,
        "temp_max": 281.15,
        "temp_min": 279.15
    },
    "name": "London",
    "sys": {
        "country": "GB",
        "id": 5091,
        "message": 0.0103,
        "sunrise": 1485762037,
        "sunset": 1485794875,
        "type": 1
    },
    "visibility": 10000,
    "weather": [
        {
            "description": "light intensity drizzle",
            "icon": "09d",
            "id": 300,
            "main": "Drizzle"
        }
    ],
    "wind": {
        "deg": 80,
        "speed": 4.1
    }
}

Если вы смущены наличием API-ключа в примере сверху, не переживайте, это тестовый API-ключ, предоставляемый сервисом.

Более важное наблюдение заключается в том, что мы отправляем два параметра (обозначаемые == при использовании HTTPie), чтобы узнать текущую погоду:

  • q — место, в котором мы хотим узнать погоду;
  • appid — наш API-ключ.

Это позволяет нам создать простую реализацию на Python с использованием библиотеки requests (опустим обработку ошибок и неудачных запросов для простоты):

import requests

SAMPLE_API_KEY = 'b1b15e88fa797225412429c1c50c122a1'

def current_weather(location, api_key=SAMPLE_API_KEY):
    url = 'http://samples.openweathermap.org/data/2.5/weather'

    query_params = {
        'q': location,
        'appid': api_key,
    }

    response = requests.get(url, params=query_params)

    return response.json()['weather'][0]['description']

Эта функция делает простой запрос к API, используя два параметра. В качестве обязательного аргумента она принимает location (местоположение), которое должно быть строкой. Также мы можем указать API-ключ, передавая параметр api_key при вызове функции. Это необязательно, так как по умолчанию используется тестовый ключ.

И вот мы видим текущую погоду в Python REPL:

>>> current_weather('London')
'light intensity drizzle'  # впрочем, ничего нового ?

Парсим обязательные параметры с click

Простая функция current_weather позволяет нам создать CLI с местоположением, указанным пользователем. Это должно работать примерно так:

$ python3 cli.py London
The weather in London right now: light intensity drizzle.

Как вы, возможно, догадались, местоположение — это аргумент, поскольку оно является обязательным параметром для нашего погодного CLI.

Как нам сделать это при помощи click? Всё довольно просто, мы используем декоратор под названием argument. Кто бы мог подумать?

Давайте возьмём наш предыдущий пример и слегка изменим его, добавив аргумент location:

@click.command()
@click.argument('location')
def main(location):
    weather = current_weather(location)
    print(f"The weather in {location} right now: {weather}.")

Если этот print выглядит для вас странно, не волнуйтесь — это новый способ форматирования строк в Python 3.6+, который называется f-форматированием.

Как вы видите, всё, что нам нужно сделать, это добавить дополнительный декоратор к нашей функции main и дать ему имя. Click использует имя в качестве имени аргумента, переданного обёрнутой функции.

Примечание переводчика Имя аргумента, переданное click, должно совпадать с именем аргумента в объявлении функции.

В нашем случае значение аргумента командной строки location будет передано функции main в качестве аргумента location. Логично, не так ли?

Также вы можете использовать тире в именах, например api-key, которые click переведёт в snake case для имени аргумента в функции, например main(api_key).

Реализация main просто использует нашу функцию current_weather для получения погоды в указанном месте. И затем мы с помощью print выводим полученную информацию.

Готово!

Парсим опциональные параметры с click

Как вы, возможно, догадались, тестовый API ограничивает нас в возможностях. Поэтому, прежде чем мы продолжим, зарегистрируйтесь и получите настоящий API-ключ.

Первое, что нам нужно изменить, — URL, откуда берутся данные о текущей погоде. Это можно сделать, изменив значение переменной url в функции current_weather на URL, указанный в документации OpenWeatherMap:

def current_weather(location, api_key=SAMPLE_API_KEY):
    url = 'https://api.openweathermap.org/data/2.5/weather'

    # дальше всё остаётся как было
    ...

Это изменение приведёт к неработоспособности нашего CLI, так как указанный API-ключ не работает с реальным API. Поэтому давайте добавим новый параметр в наш CLI, который позволит нам указывать API-ключ. Но сначала мы должны решить, будет ли этот параметр аргументом или опцией. Мы сделаем его опцией, так как добавление параметра вроде --api-key делает его более явным и говорящим за себя.
Мы хотим, чтобы наша программа запускалась таким образом:

$ python3 cli.py --api-key  London
The weather in London right now: light intensity drizzle.

Проще простого. Посмотрим, как добавить опцию к нашей существующей команде:

@click.command()
@click.argument('location')
@click.option('--api-key', '-a')
def main(location, api_key):
    weather = current_weather(location, api_key)
    print(f"The weather in {location} right now: {weather}.")

И снова мы добавляем декоратор к нашей функции main. В этот раз мы используем декоратор с говорящим именем @click.option и указываем имя для нашей опции, начинающееся с двух тире. Как вы видите, мы также можем указать сокращение для нашей опции с одним тире, чтобы сэкономить пользователю немного времени.

Как было сказано ранее, click создаёт аргумент для передачи в функцию main из длинного варианта имени. В случае с опцией он убирает впередистоящие тире и переводит её в snake case. Таким образом, --api-key становится api_key.

Чтобы всё заработало, осталось лишь передать API-ключ в функцию current_weather.

Мы добавили возможность указывать свой собственный ключ и проверять погоду в любом месте:

$ python3 cli.py --api-key  Canmore
The weather in Canmore right now: broken clouds.

Добавляем автоматически генерируемые инструкции по использованию

Можете себя похвалить, вы создали отличный небольшой CLI почти без шаблонного кода. Однако прежде чем вы решите отдохнуть, давайте убедимся, что новый пользователь будет знать, как пользоваться нашим CLI, путём добавления документации. Не бойтесь, всё будет просто.

Сначала давайте проверим, что выведет флаг --help после всех сделанных изменений. Довольно неплохо, учитывая что мы не приложили к этому никаких усилий:

$ python3 cli.py --help
Usage: cli.py [OPTIONS] LOCATION

Options:
  -a, --api-key TEXT
  --help              Show this message and exit.

Первое, что нужно исправить, это добавить описание для нашей опции с API-ключом. Всё, что нам для этого нужно сделать, — добавить справочный текст в декоратор @click.option:

@click.command()
@click.argument('location')
@click.option(
    '--api-key', '-a',
    help='your API key for the OpenWeatherMap API',
)
def main(location, api_key):
    ...

Второе (и последнее), что мы сделаем, — добавим документацию для всей click-команды. Самый простой и самый питонический способ сделать это — добавить строку документации в нашу функцию main. Да, нам в любом случае нужно сделать это, поэтому это не лишняя работа:

...
def main(location, api_key):
    """
    A little weather tool that shows you the current weather in a LOCATION of
    your choice. Provide the city name and optionally a two-digit country code.
    Here are two examples:

    1. London,UK

    2. Canmore

    You need a valid API key from OpenWeatherMap for the tool to work. You can
    sign up for a free account at https://openweathermap.org/appid.
    """
    ...

Сложив всё вместе, мы получаем хороший вывод для нашего инструмента:

$ python3 cli.py --help
Usage: cli.py [OPTIONS] LOCATION

  A little weather tool that shows you the current weather in a LOCATION of
  your choice. Provide the city name and optionally a two-digit country
  code. Here are two examples:

  1. London,UK

  2. Canmore

  You need a valid API key from OpenWeatherMap for the tool to work. You can
  sign up for a free account at https://openweathermap.org/appid.

Options:
  -a, --api-key TEXT  your API key for the OpenWeatherMap API
  --help              Show this message and exit.

Подводим итоги

Итак, в этом уроке мы рассмотрели много всего. Можете гордиться собой, вы написали свой собственный CLI, и всё это с минимальным количеством шаблонного кода! Исходный код ниже доказывает это. Не стесняйтесь использовать его для собственных экспериментов:

import click
import requests

SAMPLE_API_KEY = 'b1b15e88fa797225412429c1c50c122a1'


def current_weather(location, api_key=SAMPLE_API_KEY):
    url = 'https://api.openweathermap.org/data/2.5/weather'

    query_params = {
        'q': location,
        'appid': api_key,
    }

    response = requests.get(url, params=query_params)

    return response.json()['weather'][0]['description']


@click.command()
@click.argument('location')
@click.option(
    '--api-key', '-a',
    help='your API key for the OpenWeatherMap API',
)
def main(location, api_key):
    """
    A little weather tool that shows you the current weather in a LOCATION of
    your choice. Provide the city name and optionally a two-digit country code.
    Here are two examples:
    1. London,UK
    2. Canmore
    You need a valid API key from OpenWeatherMap for the tool to work. You can
    sign up for a free account at https://openweathermap.org/appid.
    """
    weather = current_weather(location, api_key)
    print(f"The weather in {location} right now: {weather}.")


if __name__ == "__main__":
    main()

Перевод статьи «Writing Python Command-Line Tools With Click»

In the previous lesson, Introduction to the Python language, we talked about the Python language
itself. In today’s lesson, we’re going to focus on IDLE. We’ll show you how to
use it and program a simple console application.

IDLE stands for Integrated DeveLopment Environment and it comes with Python.
In a nutshell, it’s an application that lets us write code, and then use it to
run, test and debug our applications.

Installation

Of course, we’ll have to start by installing Python. You can download it on
the official site — https://www.python.org/downloads/.
Download the latest Python 3.X version from the
Download section.

Downloading the Python programming langage and IDLE

Once you run the file, the installation wizard will execute. Check the «Add
Python 3.X to PATH» which will register the interpreter to your command line and
make running your future projects easier. Aside from that, we’ll get by with the
express installation — click «Install Now».

Installing Python on Windows

Backup and version control

Programmers usually need a tool that provides version control and backup of
their work. We can’t rely on the fact that we could simply save the program
because we’re humans and humans make mistakes. When you lose a few days’ or even
a few weeks’ work, it can be really demotivating. It’s good to think about this
sort of situation right from the start. I highly recommend Dropbox, which is
extremely simple, and automatically stores multiple versions of
your files, which makes it possible to revert to previous versions of the
project, and also synchronizes with a web repository. Even if
you’ve accidentally deleted your project, overwrote it, somebody stole your
laptop or your hard drive accidentally collapsed, your data will remain safe.
Dropbox also allows you to share projects with several developers. You can
download Dropbox at https://www.dropbox.com.

You can also use a tool called GIT for the same purposes, but its
configuration would require a whole article. Dropbox is perfect for our current
intents and purposes.

The Shell

Now, let’s run IDLE. Since the installation didn’t add a shortcut to our
Desktop, enter the «Start» menu and type «IDLE». Then, click on the search
result with the right mouse button and choose «Pin to Taskbar».

IDLE taskbar shortcut in Windows

You can now start IDLE by clicking on the Taskbar icon. The first run might
take some time.

Python IDLE in Windows Taskbar

You should now see the Python Shell window. It’s a terminal through which we
can send commands to the Python interpreter. It also displays the outputs.

Hello world

As tradition instructs, we’ll make the first program most people make when
they learn a new language — Hello World. This is a program that displays
"Hello world" or some similar text.

Let’s call a function that prints out text, as you may have expected, it’s
called print(). Some functions require input parameters. In this
case, print() requires the value to be printed as a parameter. We
specify function parameters in the parentheses after the function name. In
Python 3, we always write parentheses (even if the function doesn’t require any
parameters). This is a major difference when compared to the older Python 2.

Write the following into IDLE and press enter:

print("Hello world")

In programming, texts are called strings, like a string of characters, and
are written in quotation marks. Python sees the quotation marks and interprets
it as text. Without the quotes, the text could easily be misinterpreted with
other language commands. We can use both double or single quotes.

The result should look like this:

Python IDLE

By the way, you can use the Python console as an advanced calculator. Try
entering math problems like:

10 * 20
Console application
200

Creating a project

Ok, this was interesting but we’d like to create actual programs instead of
sending commands through the console. Click on «File» in the IDLE application
menu and select «New File».

A new Window will appear. Now, let’s write the Hello world program again.
This time, in a separate project file.

#!/usr/bin/python3

print("Hello ICT.social")
print("We'll learn more in the next lesson!")

The first line is optional but it’s better to introduce it. It is designed to
help run the script on some systems since it specifies the Python version.

Let’s save our script by pressing Ctrl + s or (File
-> Save) from the application menu. If you decided to use Dropbox, create a
new folder in your Dropbox folder (C:usesyour_usernameDropbox),
e.g. python. We’ll name our first program hello.py.

You can run the program by pressing F5 or (Run -> Run Module)
from the application menu.

The result:

Console application
Hello ICT.social
We'll learn more in the next lesson!

Congratulations, you have just become a programmer :) That will be all for today. In
the next lesson, Variables, type system and type conversions in Python, we’ll look at the basic data types and create a simple
calculator.

Today’s project is attached as a file at the end of the article. You can
always download the result below each lesson. I suggest that you create a
project using the tutorial, and if something in your project doesn’t work, then
download it to find a mistake. If you download the lesson code before even
trying to make it yourself, you won’t learn anything :)

Наберём код в текстовом редакторе (например, в gedit). Потом мы разберёмся, что делает каждая строка и пойдём дальше.

Введите эти 4 строки:

#!/usr/bin/env python3
print ("Hello. I am a python program.")
name = input("What is your name?")
print ("Hello there, " + name + "!")

Это всё. Сохраните файл как hello.py в любом месте. Я предлагаю сохранить его в папке python_examples в домашнем каталоге. Этот пример показывает, как просто написать программу на Python. Для запуска программы нужно сделать её файл исполняемым. Для этого введите в терминале

chmod +x hello.py

в папке, в которой вы сохранили программу. Теперь запустите её.

greg@earth:~/python_examples$ ./hello.py
Hello. I am a python program.
What is your name? Ferd Burphel
Hello there, Ferd Burphel!
greg@earth:~/python_examples$

Вот и всё. Теперь давайте рассмотрим что делает каждая строка программы.

#!/usr/bin/env python3

Эта строка сообщает системе, что для запуска программы необходимо использовать интерпретатор Python версии 3.

print ("Hello. I am a python program.")

Эта строка просто печатает в окне терминала Hello. I am a python program.

name = input("What is your name? ")

Эта строка немного сложнее. В ней две части. Первая часть: name = и вторая часть: input(«What is your name? «). Сначала рассмотрим вторую часть. Команда input печатает вопрос в терминале («What is your name? ») и ожидает, пока пользователь (вы) не напечатает что-нибудь (и не нажмёт {Enter}). Теперь рассмотрим первую часть команды: name =. Она создаёт переменную под названием «name». Что такое переменная? Переменную можно представить себе в виде ящика. В ящике можно хранить вещи: ботинки, части компьютера, бумаги, всё, что туда влезет. Ящику всё равно, что лежит внутри — оно просто лежит там. В нашем примере он хранит то, что вы напечатаете. Я напечатал Ferd Burphel. В данной программе Python просто берёт введённое значение и сохраняет его в ящике «name», чтобы использовать в программе в дальнейшем.

print ("Hello there, " + name + "!")

В этой строке мы опять используем команду print для вывода текста на экран, в данном случае для вывода «Hello there, » и того, что хранится в переменной «name» и восклицательного знака в конце. В данной строке мы соединяем три куска текста: «Hello there», информацию в переменной «name» и восклицательный знак.

Теперь давайте быстро обсудим то, что мы сделали, и перейдём к работе над следующим примером. Откройте окно терминала и наберите:

python3

Вы должны увидеть что-то наподобие этого:

greg@earth:~/python_examples$ python3
Python 3.4.3 (default, Oct 14 2015, 20:28:29)
[GCC 4.8.4] on linux
Type "help", "copyright", "credits" or "license" for more information.

Теперь вы находитесь в командной оболочке python. Здесь вы можете выполнять действия, но для начала разберёмся с тем, что у нас получилось. Первым делом вы увидите версию интерпретатора python — у меня стоит 3.4.3. Затем вы увидите сообщение о том, что для получения справки необходимо набрать «help» в командной строке. Это я оставляю Вам в качестве домашнего задания. А пока наберите:

print (2+2)

и нажмите Enter. вы увидите:

print (2+2)
4

Обратите внимание, что мы набрали слово «print» в нижнем регистре. Что произойдёт, если набрать «Print (2+2)»? Интерпретатор ответит следующим образом:

Print (2+2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'Print' is not defined

Это произошло потому, что слово «print» является зарегистрированной командой, а слово «Print» не является таковой. Для Python регистр очень важен.

Теперь поработаем с переменными ещё раз. Введите:

var = 2+2

Вы увидите, что ничего не происходит, за исключением того, что Python отобразит приглашение «>>>». Всё нормально. Мы поручили Python создать переменную (ящик) под названием «var» и положить в неё результат сложения «2+2». Чтобы увидеть, что теперь находится в переменной «var», введите:

print (var)

и нажмите Enter.

print (var)
4

Теперь мы можем снова использовать переменную «var» как число 4. Например, так:

print (var * 2)
8

Если мы снова наберём «print (var)», то увидим следующее:

print (var)
4

Переменная «var» не изменилась. В ней осталась храниться сумма 2+2, то есть 4.

Конечно, данная программа очень простая и написана для этого урока для начинающих. В последующих уроках программы станут более сложными. А сейчас давайте рассмотрим ещё несколько примеров с переменными.

Введите в интерпретаторе:

strng = 'The time has come for all good men to come to the aid of the party!'
print (strng)
The time has come for all good men to come to the aid of the party!

Вы создали переменную под названием «strng», которая содержит значение «The time has come for all good men to come to the aid of the party!». С этого момента (пока мы работаем с этим экземпляром интерпретатора) переменная «strng» будет содержать то же значение, пока мы её не изменим. Что будет, если мы попробуем умножить эту переменную на 4?

print (strng * 2)
The time has come for all good men to come to the aid of the party!The time has come for all good men to come to the aid of the party!

Произошло не совсем то, что вы ожидали. Python напечатал значение переменной «strng» 2 раза. Почему? Интерпретатор знал, что переменная «strng» — это строка, а не число. Невозможно производить математические операции над строками.

Если бы у нас была переменная под названием «s», которая содержала бы значение «4» как показано ниже:

s = '4'
print (s)
4

Кажется, что переменная «s» содержит число 4, но это не так. Она содержит текстовое значение «4». И если мы наберем «print (s * 4)», то получим…

print (s*4)
4444

Снова интерпретатор знает, что переменная «s» — это строка, а не число. Он знает это, потому что мы взяли число 4 в одинарные кавычки, что означает, что оно является строкой.
Мы можем проверить это, набрав «print type(s)», чтобы увидеть, какой тип система присвоила данной переменной.

print (type(s))
<class 'str'>

Подтверждено. Переменная имеет тип «строка». Если мы захотим использовать её в качестве числа, то мы напечатаем следующее:

print (int(s) * 4)
16

Строка «s», содержащая значение «4», преобразована в число и умножена на 4, что равно 16.

Теперь у Вас есть основные знания о командах print и input, создании переменных и разнице между строками и числами.

Пойдём дальше. В интерпретаторе Python введите quit() для выхода в командную строку.

Теперь давайте рассмотрим пример программирования цикла. Запустите текстовый редактор и наберите следующую программу:

#!/usr/bin/env python3
for cntr in range(0,10):
    print (cntr)

Не забудьте поставить символ табуляции перед строкой «print (cntr)». Это важно. В Python для указания на блоки кода используются не круглые «(» и фигурные «{» скобки как в других языках программирования, а отступы.

Сохраните программу под именем «for_loop.py». До её запуска давайте поговорим о том, что такое цикл «for».

Цикл — это код, выполняющий определённую инструкцию или набор инструкций определённое число раз. В нашей программе мы выполним цикл 10 раз, печатая значение переменной «cntr». Вот перевод команды на русский язык «присвоить переменной «cntr» значение 0, выполнить печать значения переменной «cntr», прибавить к переменной «cntr» 1 и повторить это 10 раз». Выглядит довольно просто. Часть кода «range(0,10)» говорит: начать с 0, повторять, пока значение переменной «cntr» не будет равно 10, и выйти.

Теперь, как раньше, выполните команду

chmod +x for_loop.py

и запустите программу:

./for_loop.py

в терминале.

greg@earth:~/python_examples$ ./for_loop.py
0
1
2
3
4
5
6
7
8
9
greg@earth:~/python_examples$

Кажется, программа работает, но почему она считает только до 9 и останавливается? Посмотрите её вывод. Напечатано 10 цифр: от 0 до 9. Это то, что мы запрограммировали: напечатать значение переменной «cntr» 10 раз, каждый раз добавляя единицу к переменной, и выйти, когда её значение будет равно 10.

Теперь вы видите, что программирование может быть как простым, так и сложным. Вы должны точно знать, что вы хотите от программы. Если вы измените команду «range» на «range(1,10)», она начнёт считать с 1, но остановится на 9, так как при достижении верхнего порога (10) цикл прерывается. Чтобы заставить программу печатать «1,2,3,4,5,6,7,8,9,10», надо заменить команду на «range(1,11)», так как цикл «for» прерывается при достижении второго числа в команде «range».

Обратите также внимание на синтаксис цикла. Двоеточие в команде «for переменная in range(начальное значение,конечное значение):» означает начало блока кода, который должен быть смещен вправо. Вы должны использовать двоеточие «:» и смещать код вправо до конца блока — это очень важно.

Если мы изменим нашу программу таким образом:

#!/usr/bin/env python
for cntr in range(1,11):
    print (cntr)
print ("All Done")

Мы увидим на экране следующее:

greg@earth:~/python_examples$ ./for_loop.py
1
2
3
4
5
6
7
8
9
10
All Done
greg@earth:~/python_examples$

Убедитесь в том, что вы соблюдаете уровни вложенности. Запомните: от них зависит выделение блоков. В следующем уроке мы подробнее рассмотрим уровни вложенности.

На сегодня хватит. В следующем уроке мы продолжим изучать команды языка Python. А пока, вам стоит попробовать специализированные редакторы для Python, например, Dr. Python или SPE (Stani’s Python Editor). Обе программы вы найдёте в Synaptic.


21 января 2022 г. | Python

Python часто называют языком межпрограммной склейки, потому что он чрезвычайно гибкий и хорошо интегрируется с существующими программами. На текущий момент уже написано огромное количество кода в виде скриптов с интерфейсом командной строки.

Разработка приложений с интерфейсом командной строки (CLI) чрезвычайно полезна, так как позволяет автоматизировать практически все. Но, со временем, CLI программы могут стать довольно сложными.
Рассмотрим простой Python скрипт, который обращается по веб-API к серверу и распечатывает выходные данные в консоль:

print_user_agent.py

import requests
json = requests.get('http://httpbin.org/user-agent').json()
print(json['user-agent'])

Скрипт распечатает User-Agent пользователя, выполнив вызов к API.
Но что делать, когда Python скрипт командной строки начнёт расти и усложняться?

В данной статье будет рассказано:

  • Почему click – лучшая альтернатива argparse и optparse
  • Как создавать программы с простым CLI
  • Как добавить обязательные аргументы командной строки в скрипты
  • Как парсить флаги и параметры командной строки
  • Как сделать приложения для командной строки более удобными, добавив текст справки

Все примеры кода в этом руководстве реализованы на Python 3.6. Они могут не работать с более ранними версиями Python, но если у вас возникнут проблемы, оставьте комментарий.

Зачем нам писать скрипты и инструменты командной строки на Python?

Приведенный выше фрагмент кода является простым примером и поэтому бесполезен. Реальные сценарии намного сложнее. Обычно они помогают создавать, тестировать и развертывать приложения, выполняя этот процесс автоматически.

В таких случаях очень важно писать скрипты более гибкими и настраиваемыми. Это позволяет передавать в сценарии имена серверов, учетные данные или любую другую информацию.

В стандартную библиотеку Python входят модули optparse и argparse, которые делают жизнь разработчика намного проще. Но прежде чем рассматривать их более подробно, давайте введём терминологию.

Основы интерфейса командной строки

Интерфейс командной строки (CLI) начинается с имени исполняемого файла. Введя его имя в консоли, пользователь получает доступ к главной точке входа в скрипт, например, к pip.

Основными параметрами передаваемые в CLI приложение можно разделить на следующие группы:

  • Аргументы – обязательные параметры, передаваемые скрипту. Если их не определять, CLI вернет ошибку. Например, django– это аргумент в команде pip install django.
  • Опции – необязательные ([]) параметры, объединяющие имя и часть значения, например -cache-dir ./my-cache. Программе pip сообщается, что значение ./my-cache должно использоваться для определения каталога кэша.
  • Флаги – специальные опции, которые включают или отключают определенное поведение. Чаще всего это —help.

Вероятно, вы уже использовали CLI, когда устанавливали Python пакет, выполнив pip install <PACKAGE NAME>. Команда install сообщает CLI, что необходимо получить доступ к функции устанавливающей пакет и предоставить доступ к параметрам, характерным для этой функции.

Фреймворки командной строки, доступные в стандартной библиотеке Python 3.x

Добавление команд и опций в скрипты чрезвычайно полезно, но разбор командной строки не так очевиден, как это может показаться. Вместо того, чтобы начинать писать свои собственные парсеры, рационально воспользоваться одним из множества Python пакетов, которые уже решили эту задачу.

Двумя наиболее известными пакетами являются optparse и argparse. Они являются частью стандартной библиотеки Python по принципу «батарейки идут в комплекте».

Они в основном обеспечивают схожую функциональность и работают очень похоже. Самое большое отличие заключается в том, что библиотека optparse устарела и запрещена в Python 3.2, а argparse считается стандартом для реализации CLI в Python.

Более подробную информацию о них можно найти в документации по Python. Чтобы иметь представление как выглядит скрипт с argparse, приведем пример:

import argparse
parser = argparse.ArgumentParser(description='Процессор над числами.')
parser.add_argument('integers', metavar='N', type=int, nargs='+',
                    help='integer число для аккумулятора')
parser.add_argument('--sum', dest='accumulate', action='store_const',
                    const=sum, default=max,
                    help='сумма integer чисел (по умолчанию поиск максимального)')

args = parser.parse_args()
print(args.accumulate(args.integers))

Код приведённый выше нельзя назвать интуитивно понятным и легко читаемым. Поэтому рекомендуется использовать click.

Использование click в качестве лучшей альтернативы

click разрешает ту же задачу, что и optparse и argparse, но использует несколько иной подход. Он использует концепцию декораторов. Это требует реализацию команд в виде функций, которые потом можно обернуть декоратором.

Основная причина, почему используют click – это то, что можно легко создать многофункциональный CLI с небольшим количеством кода. При этом код остаётся легко читаемым, даже когда CLI растет и становится более сложным.

Простой интерфейс командной строки Python с click

Создадим простое CLI приложение на основе click, который распечатывает текст в консоль.

# cli.py
import click

@click.command()
def main():
    print("Hello, World!")

if __name__ == "__main__":
    main()

Всё что нужно сделать – это создать функцию и добавить к ней декоратор @click.command(). Он превращает функцию main в команду click, которая является точкой входа для скрипта. После запуска скрипта получим результат:

$ python cli.py
Hello, World!

Прелесть click – это то, что мы бесплатно получаем некоторые дополнительные функции. Не реализовывая никаких вспомогательных возможностей добавив параметр —help, распечатается основная страница справки в командную строку:

$ python cli.py --help
Usage: cli.py [OPTIONS]

Options:
  --help  Show this message and exit.

Более реалистичный пример CLI с click

Теперь, когда известно, как click упрощает создание простого CLI, рассмотрим более реалистичный пример. Напишем программу, которая позволяет взаимодействовать с веб API. Сегодня многие используются ими, потому что они предоставляют доступ к некоторым любопытным данным.

API который будет рассматриваться для оставшейся части этого материала – это OpenWeatherMap API. Он сообщает текущую погоду, а также прогноз на пять дней для определенного местоположения. Начнем с шаблона API, возвращающего текущую погоду для местоположения.

Одним из инструментов, который позволяет исследовать новый API, является программа HTTPie, которую можно использовать для вызова API ресурса и просмотра возвращаемого результата.

Давайте посмотрим, что произойдет, когда будет вызван API для города Москва:

$ http --body GET http://samples.openweathermap.org/data/2.5/weather q==Moskow 
  appid==a1v15e88fa797225412429c1c50c345a1
{
    "base": "stations",
    "clouds": {
        "all": 90
    },
    "cod": 200,
    "coord": {
        "lat": 51.51,
        "lon": -0.13
    },
    "dt": 1485789600,
    "id": 2643743,
    "main": {
        "humidity": 81,
        "pressure": 1012,
        "temp": 280.32,
        "temp_max": 281.15,
        "temp_min": 279.15
    },
    "name": "Moskow",
    "sys": {
        "country": "RU",
        "id": 5091,
        "message": 0.0103,
        "sunrise": 1485762037,
        "sunset": 1485794875,
        "type": 1
    },
    "visibility": 10000,
    "weather": [
        {
            "description": "light intensity drizzle",
            "icon": "09d",
            "id": 300,
            "main": "Drizzle"
        }
    ],
    "wind": {
        "deg": 80,
        "speed": 4.1
    }
}

Важным наблюдением из приведенного выше примера является то, что для получения текущей погоды определяется два параметра запроса (обозначаемые == при использовании HTTPie) :

  • q – местонахождение
  • appid — это ключ к API

Также можно создать простую реализацию с использованием Python и библиотеки Requests (будем игнорировать обработку ошибок и неудачные запросы для простоты.)

import requests

SAMPLE_API_KEY = 'a1v15e88fa797225412429c1c50c345a1'
def current_weather(location, api_key=SAMPLE_API_KEY):
    url = 'http://samples.openweathermap.org/data/2.5/weather'
    query_params = {
        'q': location,
        'appid': api_key,
    }
    response = requests.get(url, params=query_params)
    return response.json()['weather'][0]['description']

Функция создаёт простой запрос к API погоды, используя два параметра запроса. Она принимает обязательный аргумент location, который является строкой. Также можно предоставить ключ API, передав api_key в вызове функции. Он является необязательным и используется для примера по умолчанию.

Результат работы программы для Москвы

>>> current_weather('Moskow')
'light intensity drizzle'

Парсинг обязательных параметров с click

Простая функция current_weather позволяет создавать собственный CLI с передачей пользователем аргумента location. Результат работы программы должен быть таким:

$ python cli.py Moskow
The weather in Moskow right now: light intensity drizzle.

Рассмотрим простой пример, немного изменив предыдущий, указав аргумент location.

@click.command()
@click.argument('location')
def main(location):
    weather = current_weather(location)
    print(f"The weather in {location} right now: {weather}.")

Всё, что нужно сделать, это добавить дополнительный декоратор к функции main и дать ему имя. click использует это имя в качестве имени аргумента, передаваемого в функцию обёртку.

В нашем случае значение расположения будет передано в функцию main через аргумент location.

Можно также использовать тире (-) в именах, таких как API-ключ, который click превратит в «змеиный регистр» для имени аргумента в функции, например main(api_key).

Реализация основывается на простой функции current_weather, которая запрашивает погоду для location. Далее вызывается оператор print для вывода информации о погоде.

Оператор print выглядит немного странно, потому что это новый способ форматирования строк добавленный в Python 3.6+, называемый форматированием f-string.

Парсинг необязательных параметров с click

На данный момент функция current_weather всегда возвращает погоду в Москве начиная с января 2017 года. Также нужно определить реальный ключ к API. Для бесплатного получения ключа доступа зарегистрируйтесь на сайте openweathermap.

Изменим URL для текущей погоды location.

def current_weather(location, api_key=SAMPLE_API_KEY):
    url = 'https://api.openweathermap.org/data/2.5/weather'

    # все остальное остается прежним    
...

Произведённые изменения, ломают CLI приложения, поскольку ключ API по умолчанию недействителен. API вернет код статуса HTTP 401 UNAUTHORIZED.

$ http GET https://api.openweathermap.org/data/2.5/weather q==Moskow appid==a1v15e88fa797225412429c1c50c345a1
HTTP/1.1 401 Unauthorized
{
    "cod": 401,
    "message": "Invalid API key."
}

Итак, добавим новый параметр в CLI, который позволяет указать ключ API. Но сначала нужно определиться, должен это быть аргумент или опция. Определим параметр как опция, потому что добавление именованного параметра, такого как —api-key, делает его более явным и очевидным.

$ python cli.py --api-key <your-api-key> Moskow
The weather in Moskow right now: light intensity drizzle.

Модифицированная версия CLI приложения

@click.command()
@click.argument('location')
@click.option('--api-key', '-a')
def main(location, api_key):
    weather = current_weather(location, api_key)
    print(f"The weather in {location} right now: {weather}.")

Таким образом добавлен ещё один декоратор функции main. На этот раз используется декоратор @click.option, добавляя опцию с ведущими двойными тире (-). Также можно определить краткую опцию с одним тире (-), чтобы реализовать краткий пользовательский ввод.

Как уже было сказано, click создает аргумент, переданный функции main из длинной версии имени. В случае опции, фреймворк распаковывает ведущие тире и превращает их в snake case. —API-key превращается в api_key.

Теперь пользователь CLI приложения может использовать собственный ключ и определить любое местоположение:

$ python cli.py --api-key <your-api-key> Novosibirsk
The weather in Novosibirsk right now: broken clouds.

Добавление справки по использованию приложения

Далее реализуем возможность вывода краткой документации для начинающего пользователя.

Сначала проверим, что распечатает приложение после запуска с флагом — -help.

$ python cli.py --help
Usage: cli.py [OPTIONS] LOCATION

Options:
  -a, --api-key TEXT
  --help              Show this message and exit.

Первое, что нужно исправить – это отсутствие описания для ключа API. Поэтому определим текст справки для декоратора @click.option:

@click.command()
@click.argument('location')
@click.option(
    '--api-key', '-a',
    help='your API key for the OpenWeatherMap API',
)
def main(location, api_key):

В заключение добавим справочную информацию о назначении и кратких примерах использования. Самый простой и самый питоничный способ – это добавление docstring к функции main. Это необходимо выполнять в любом случае, так что это даже не лишняя работа:

...
def main(location, api_key):
    """
    A little weather tool that shows you the current weather in a LOCATION of
    your choice. Provide the city name and optionally a two-digit country code.
    Here are two examples:

    1. Moskow

    2. Novosibirsk

    You need a valid API key from OpenWeatherMap for the tool to work.
    """
    ...

Объединяя все изменения, получаем результат:

$ python cli.py --help
Usage: cli.py [OPTIONS] LOCATION

  A little weather tool that shows you the current weather in a LOCATION of
  your choice. Provide the city name and optionally a two-digit country
  code. Here are two examples:

  1. Moskow

  2. Novosibirsk

  You need a valid API key from OpenWeatherMap for the tool to work..

Options:
  -a, --api-key TEXT  your API key for the OpenWeatherMap API
  --help              Show this message and exit.

Результаты и резюме

Итак, в данной статье были даны ответы на следующие вопросы:

  • Почему click лучше, чем argparse и optparse
  • Как создать простой CLI
  • Как добавить обязательные аргументы командной строки в скрипты
  • Как парсить флаги и параметры командной строки
  • Как сделать приложения для командной строки более удобными, добавив текст справки

Ниже приведен полный пример кода. Не стесняйтесь использовать его в своих проектах

import click
import requests

SAMPLE_API_KEY = 'a1v15e88fa797225412429c1c50c345a1'


def current_weather(location, api_key=SAMPLE_API_KEY):
    url = 'https://api.openweathermap.org/data/2.5/weather'

    query_params = {
        'q': location,
        'appid': api_key,
    }

    response = requests.get(url, params=query_params)

    return response.json()['weather'][0]['description']


@click.command()
@click.argument('location')
@click.option(
    '--api-key', '-a',
    help='your API key for the OpenWeatherMap API',
)
def main(location, api_key):
    """
    A little weather tool that shows you the current weather in a LOCATION of
    your choice. Provide the city name and optionally a two-digit country code.
    Here are two examples:
    1. Moskow
    2. Novosibirsk
    You need a valid API key from OpenWeatherMap for the tool to work. 
    """
    weather = current_weather(location, api_key)
    print(f"The weather in {location} right now: {weather}.")


if __name__ == "__main__":
    main()

Building an application to manage your to-do list can be an interesting project when you’re learning a new programming language or trying to take your skills to the next level. In this tutorial, you’ll build a functional to-do application for the command line using Python and Typer, which is a relatively young library for creating powerful command-line interface (CLI) applications in almost no time.

With a project like this, you’ll apply a wide set of core programming skills while building a real-world application with real features and requirements.

In this tutorial, you’ll learn how to:

  • Build a functional to-do application with a Typer CLI in Python
  • Use Typer to add commands, arguments, and options to your to-do app
  • Test your Python to-do application with Typer’s CliRunner and pytest

Additionally, you’ll practice your skills related to processing JSON files by using Python’s json module and managing configuration files with Python’s configparser module. With this knowledge, you’ll be ready to start creating CLI applications right away.

You can download the entire code and all the additional resources for this to-do CLI application by clicking the link below and going to the source_code_final/ directory:

Demo

In this step-by-step project, you’ll build a command-line interface (CLI) application to manage a to-do list. Your application will provide a CLI based on Typer, a modern and versatile library for creating CLI applications.

Before you get started, check out this demo of how your to-do application will look and work once you get to the end of this tutorial. The first part of the demo shows how to get help on working with the app. It also shows how to initialize and configure the app. The rest of the video demonstrates how to interact with the essential features, such as adding, removing, and listing to-dos:

Nice! The application has a user-friendly CLI that allows you to set up the to-do database. Once there, you can add, remove, and complete to-dos using appropriate commands, arguments, and options. If you ever get stuck, then you can ask for help using the --help option with proper arguments.

Do you feel like kicking off this to-do app project? Cool! In the next section, you’ll plan out how to structure the layout of the project and what tools you’ll use to build it.

Project Overview

When you want to start a new application, you typically start by thinking about how you want the app to work. In this tutorial, you’ll build a to-do app for the command line. You’ll call that application rptodo.

You want your application to have a user-friendly command-line interface that allows your users to interact with the app and manage their to-do lists.

To start off, you want your CLI to provide the following global options:

  • -v or --version shows the current version and exits the application.
  • --help shows the global help message for the entire application.

You’ll see these same options in many other CLI applications out there. It’s a nice idea to provide them because most users who work with the command line expect to find them in every app.

Regarding managing a to-do list, your application will provide commands to initialize the app, add and remove to-dos, and manage the to-do completion status:

Command Description
init Initializes the application’s to-do database
add DESCRIPTION Adds a new to-do to the database with a description
list Lists all the to-dos in the database
complete TODO_ID Completes a to-do by setting it as done using its ID
remove TODO_ID Removes a to-do from the database using its ID
clear Removes all the to-dos by clearing the database

These commands provide all the functionality you need to turn your to-do application into a minimum viable product (MVP) so that you can publish it to PyPI or the platform of your choice and start getting feedback from your users.

To provide all these features in your to-do application, you’ll need to complete a few tasks:

  1. Build a command-line interface capable of taking and processing commands, options, and arguments
  2. Select an appropriate data type to represent your to-dos
  3. Implement a way to persistently store your to-do list
  4. Define a way to connect that user interface with the to-do data

These tasks relate well to what is known as the Model-View-Controller design, which is an architectural pattern. In this pattern, the model takes care of the data, the view deals with the user interface, and the controller connects both ends to make the application work.

The main reason for using this pattern in your applications and projects is to provide separation of concerns (SoC), making different parts of your code deal with specific concepts independently.

The next decision you need to make is about the tools and libraries you’ll use to tackle each of the tasks you defined further up. In other words, you need to decide your software stack. In this tutorial, you’ll use the following stack:

  • Typer to build the to-do application’s CLI
  • Named tuples and dictionaries to handle the to-do data
  • Python’s json module to manage persistent data storage

You’ll also use the configparser module from the Python standard library to handle the application’s initial settings in a configuration file. Within the configuration file, you’ll store the path to the to-do database in your file system. Finally, you’ll use pytest as a tool for testing your CLI application.

Prerequisites

To complete this tutorial and get the most out of it, you should be comfortable with the following topics:

  • The Model-View-Controller pattern
  • Command-line interfaces (CLI)
  • Python type hints, also known as type annotations
  • Unit tests with pytest
  • Object-oriented programming in Python
  • Configuration files with configparser
  • JSON files with Python’s json
  • File system path manipulation with pathlib

That’s it! If you’re ready to get your hands dirty and start creating your to-do app, then you can begin with setting up your working environment and project layout.

Step 1: Set Up the To-Do Project

To start coding your to-do application, you need to set up a working Python environment with all the tools, libraries, and dependencies you’ll use in the process. Then you need to give the project a coherent Python application layout. That’s what you’ll do in the following subsections.

To download all the files and the project structure you’ll be creating in this section, click the link below and go to the source_code_step_1/ directory:

Set Up the Working Environment

In this section, you’ll create a Python virtual environment to work on your to-do project. Using a virtual environment for each independent project is a best practice in Python programming. It allows you to isolate your project’s dependencies without cluttering your system Python installation or breaking other projects that use different versions of the same tools and libraries.

To create a Python virtual environment, move to your favorite working directory and create a folder called rptodo_project/. Then fire up a terminal or command line and run the following commands:

$ cd rptodo_project/
$ python -m venv ./venv
$ source venv/bin/activate
(venv) $

Here, you first enter the rptodo_project/ directory using cd. This directory will be your project’s root directory. Then you create a Python virtual environment using venv from the standard library. The argument to venv is the path to the directory hosting your virtual environment. A common practice is to call that directory venv, .venv, or env, depending on your preferences.

The third command activates the virtual environment you just created. You know that the environment is active because your prompt changes to something like (venv) $.

Now that you have a working virtual environment, you need to install Typer to create the CLI application and pytest to test your application’s code. To install Typer with all its current optional dependencies, run the following command:

(venv) $ python -m pip install typer==0.3.2 colorama==0.4.4 shellingham==1.4.0

This command installs Typer and all its recommended dependencies, such as Colorama, which ensures that colors work correctly on your command line window.

To install pytest, which you’ll use later to test your to-do application, run the following command:

(venv) $ python -m pip install pytest==6.2.4

With this last command, you successfully installed all the tools you need to start developing your to-do application. The rest of the libraries and tools you’ll use are part of the Python standard library, so you don’t have to install anything to use them.

Define the Project Layout

The last step you’ll run to finish setting up your to-do app project is to create the packages, modules, and files that will frame the application layout. The app’s core package will live in the rptodo/ directory inside rptodo_project/.

Here’s a description of the package’s contents:

File Description
__init__.py Enables rptodo/ to be a Python package
__main__.py Provides an entry-point script to run the app from the package using the python -m rptodo command
cli.py Provides the Typer command-line interface for the application
config.py Contains code to handle the application’s configuration file
database.py Contains code to handle the application’s to-do database
rptodo.py Provides code to connect the CLI with the to-do database

You’ll also need a tests/ directory containing a __init__.py file to turn the directory into a package and a test_rptodo.py file to hold unit tests for the application.

Go ahead and create the project’s layout with the following structure:

rptodo_project/
│
├── rptodo/
│   ├── __init__.py
│   ├── __main__.py
│   ├── cli.py
│   ├── config.py
│   ├── database.py
│   └── rptodo.py
│
├── tests/
│   ├── __init__.py
│   └── test_rptodo.py
│
├── README.md
└── requirements.txt

The README.md file will provide the project’s description and instructions for installing and running the application. Adding a descriptive and detailed README.md file to your project is a best practice in programming, especially if you plan to release the project as open source.

The requirements.txt file will provide the list of dependencies for your to-do application. Go ahead and fill it with the following contents:

typer==0.3.2
colorama==0.4.4
shellingham==1.4.0
pytest==6.2.4

Now your users can automatically install the listed dependencies by running the following command:

(venv) $ python -m pip install -r requirements.txt

Providing a requirements.txt like this ensures that your user will install the exact versions of dependencies you used to build the project, avoiding unexpected issues and behaviors.

Except for requirements.txt, all your project’s files should be empty at this point. You’ll fill each file with the necessary content moving forward through this tutorial. In the following section, you’ll code the application’s CLI with Python and Typer.

Step 2: Set Up the To-Do CLI App With Python and Typer

At this point, you should have a complete project layout for your to-do application. You should also have a working Python virtual environment with all the required tools and libraries. At the end of this step, you’ll have a functional Typer CLI application. Then you’ll be able to build on top of its minimal functionality.

You can download the code, unit tests, and resources you’ll add in this section by clicking the link below and going to the source_code_step_2/ directory:

Fire up your code editor and open the __init__.py file from the rptodo/ directory. Then add the following code to it:

"""Top-level package for RP To-Do."""
# rptodo/__init__.py

__app_name__ = "rptodo"
__version__ = "0.1.0"

(
    SUCCESS,
    DIR_ERROR,
    FILE_ERROR,
    DB_READ_ERROR,
    DB_WRITE_ERROR,
    JSON_ERROR,
    ID_ERROR,
) = range(7)

ERRORS = {
    DIR_ERROR: "config directory error",
    FILE_ERROR: "config file error",
    DB_READ_ERROR: "database read error",
    DB_WRITE_ERROR: "database write error",
    ID_ERROR: "to-do id error",
}

Here, you start by defining two module-level names to hold the application’s name and version. Then you define a series of return and error codes and assign integer numbers to them using range(). ERROR is a dictionary that maps error codes to human-readable error messages. You’ll use these messages to tell the user what’s happening with the application.

With this code in place, you’re ready to create the skeleton of your Typer CLI application. That’s what you’ll do in the following section.

Create the Typer CLI Application

In this section, you’ll create a minimal Typer CLI application with support for --help, -v, and --version options. To do so, you’ll use an explicit Typer application. This type of application is suitable for large projects that include multiple commands with several options and arguments.

Go ahead and open rptodo/cli.py in your text editor and type in the following code:

 1"""This module provides the RP To-Do CLI."""
 2# rptodo/cli.py
 3
 4from typing import Optional
 5
 6import typer
 7
 8from rptodo import __app_name__, __version__
 9
10app = typer.Typer()
11
12def _version_callback(value: bool) -> None:
13    if value:
14        typer.echo(f"{__app_name__} v{__version__}")
15        raise typer.Exit()
16
17@app.callback()
18def main(
19    version: Optional[bool] = typer.Option(
20        None,
21        "--version",
22        "-v",
23        help="Show the application's version and exit.",
24        callback=_version_callback,
25        is_eager=True,
26    )
27) -> None:
28    return

Typer uses Python type hints extensively, so in this tutorial, you’ll use them as well. That’s why you start by importing Optional from typing. Next, you import typer. Finally, you import __app_name__ and __version__ from your rptodo package.

Here’s how the rest of the code works:

  • Line 10 creates an explicit Typer application, app.

  • Lines 12 to 15 define _version_callback(). This function takes a Boolean argument called value. If value is True, then the function prints the application’s name and version using echo(). After that, it raises a typer.Exit exception to exit the application cleanly.

  • Lines 17 and 18 define main() as a Typer callback using the @app.callback() decorator.

  • Line 19 defines version, which is of type Optional[bool]. This means it can be either of bool or None type. The version argument defaults to a typer.Option object, which allows you to create command-line options in Typer.

  • Line 20 passes None as the first argument to the initializer of Option. This argument is required and supplies the option’s default value.

  • Lines 21 and 22 set the command-line names for the version option: -v and --version.

  • Line 23 provides a help message for the version option.

  • Line 24 attaches a callback function, _version_callback(), to the version option, which means that running the option automatically calls the function.

  • Line 25 sets the is_eager argument to True. This argument tells Typer that the version command-line option has precedence over other commands in the current application.

With this code in place, you’re ready to create the application’s entry-point script. That’s what you’ll do in the following section.

Create an Entry-Point Script

You’re almost ready to run your to-do application for the first time. Before doing that, you should create an entry-point script for the app. You can create this script in a few different ways. In this tutorial, you’ll do it using a __main__.py module inside the rptodo package. Including a __main__.py module in a Python package enables you to run the package as an executable program using the command python -m rptodo.

Go back to your code editor and open __main__.py from the rptodo/ directory. Then add the following code:

"""RP To-Do entry point script."""
# rptodo/__main__.py

from rptodo import cli, __app_name__

def main():
    cli.app(prog_name=__app_name__)

if __name__ == "__main__":
    main()

In __main__.py, you first import cli and __app_name__ from rptodo. Then you define main(). In this function, you call the Typer app with cli.app(), passing the application’s name to the prog_name argument. Providing a value to prog_name ensures that your users get the correct app name when running the --help option on their command line.

With this final addition, you’re ready to run your to-do application for the first time. Move to your terminal window and execute the following commands:

(venv) $ python -m rptodo -v
rptodo v0.1.0

(venv) $ python -m rptodo --help
Usage: rptodo [OPTIONS] COMMAND [ARGS]...

Options:
  -v, --version         Show the application's version and exit.
  --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.

The first command runs the -v option, which displays the app’s version. The second command runs the --help option to show a user-friendly help message for the entire application. Typer automatically generates and displays this help message for you.

Set Up Initial CLI Tests With pytest

The final action you’ll run in this section is to set up an initial test suite for your to-do application. To this end, you’ve created the tests package with a module called test_rptodo.py. As you learned earlier, you’ll use pytest for writing and running your unit tests.

Testing a Typer application is straightforward because the library integrates pretty well with pytest. You can use a Typer class called CliRunner to test the application’s CLI. CliRunner allows you to create a runner that you can use to test how your application’s CLI responds to real-world commands.

Go back to your code editor and open test_rptodo.py from the tests/ directory. Type in the following code:

 1# tests/test_rptodo.py
 2
 3from typer.testing import CliRunner
 4
 5from rptodo import __app_name__, __version__, cli
 6
 7runner = CliRunner()
 8
 9def test_version():
10    result = runner.invoke(cli.app, ["--version"])
11    assert result.exit_code == 0
12    assert f"{__app_name__} v{__version__}n" in result.stdout

Here’s what this code does:

  • Line 3 imports CliRunner from typer.testing.
  • Line 5 imports a few required objects from your rptodo package.
  • Line 7 creates a CLI runner by instantiating CliRunner.
  • Line 9 defines your first unit test for testing the application’s version.
  • Line 10 calls .invoke() on runner to run the application with the --version option. You store the result of this call in result.
  • Line 11 asserts that the application’s exit code (result.exit_code) is equal to 0 to check that the application ran successfully.
  • Line 12 asserts that the application’s version is present in the standard output, which is available through result.stdout.

Typer’s CliRunner is a subclass of Click’s CliRunner. Therefore, its .invoke() method returns a Result object, which holds the result of running the CLI application with the target arguments and options. Result objects provide several useful attributes and properties, including the application’s exit code and standard output. Take a look at the class documentation for more details.

Now that you’ve set up the first unit test for your Typer CLI application, you can run the test with pytest. Go back to your command line and execute python -m pytest tests/ from your project’s root directory:

========================= test session starts =========================
platform linux -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: .../rptodo
plugins: Faker-8.1.1, cov-2.12.0, celery-4.4.7
collected 1 item

tests/test_rptodo.py .                                          [100%]
========================== 1 passed in 0.07s ==========================

That’s it! You’ve successfully run your test suite for the first time! Yes, you only have one test so far. However, you’ll be adding more of them in upcoming sections. You can also add your own test if you want to challenge your testing skills.

With the skeleton to-do application in place, now you can think about setting up the to-do database to get it ready for use. That’s what you’ll do in the following section.

Step 3: Prepare the To-Do Database for Use

Up to this point, you’ve put together a CLI for your to-do application, created an entry-point script, and run the application for the first time. You’ve also set up and run a minimal test suite for the app. The next step is to define how your application will initialize and connect to the to-do database.

You’ll use a JSON file to store the data about your to-dos. JSON is a lightweight data-interchange format that’s human-readable and writable. Python’s standard library includes json, which is a module that provides support for the JSON file format out of the box. That’s what you’ll use to manage your to-do database.

You can download the entire code for this section by clicking the link below and going to the source_code_step_3/ directory:

At the end of this section, you’ll have written the code for creating, connecting, and initializing your to-do database so that it’s ready for use. The first step, however, is to define how your application will find the to-do database in your file system.

Set Up the Application’s Configurations

You can use different techniques to define how an application connects and opens a file on your file system. You can provide the file path dynamically, create an environment variable to hold the file path, create a configuration file in which you store the file path, and so on.

In this tutorial, you’ll provide your to-do app with a configuration file in your home directory to store the path to the database. To that end, you’ll use pathlib to work with file system paths and configparser to handle configuration files. Both packages are available for you in the Python standard library.

Now go back to your code editor and open config.py from rptodo/. Type in the following code:

 1"""This module provides the RP To-Do config functionality."""
 2# rptodo/config.py
 3
 4import configparser
 5from pathlib import Path
 6
 7import typer
 8
 9from rptodo import (
10    DB_WRITE_ERROR, DIR_ERROR, FILE_ERROR, SUCCESS, __app_name_
11)
12
13CONFIG_DIR_PATH = Path(typer.get_app_dir(__app_name__))
14CONFIG_FILE_PATH = CONFIG_DIR_PATH / "config.ini"
15
16def init_app(db_path: str) -> int:
17    """Initialize the application."""
18    config_code = _init_config_file()
19    if config_code != SUCCESS:
20        return config_code
21    database_code = _create_database(db_path)
22    if database_code != SUCCESS:
23        return database_code
24    return SUCCESS
25
26def _init_config_file() -> int:
27    try:
28        CONFIG_DIR_PATH.mkdir(exist_ok=True)
29    except OSError:
30        return DIR_ERROR
31    try:
32        CONFIG_FILE_PATH.touch(exist_ok=True)
33    except OSError:
34        return FILE_ERROR
35    return SUCCESS
36
37def _create_database(db_path: str) -> int:
38    config_parser = configparser.ConfigParser()
39    config_parser["General"] = {"database": db_path}
40    try:
41        with CONFIG_FILE_PATH.open("w") as file:
42            config_parser.write(file)
43    except OSError:
44        return DB_WRITE_ERROR
45    return SUCCESS

Here’s a breakdown of what this code does:

  • Line 4 imports configparser. This module provides the ConfigParser class, which allows you to handle config files with a structure similar to INI files.

  • Line 5 imports Path from pathlib. This class provides a cross-platform way to handle system paths.

  • Line 7 imports typer.

  • Lines 9 to 11 import a bunch of required objects from rptodo.

  • Line 13 creates CONFIG_DIR_PATH to hold the path to the app’s directory. To get this path, you call get_app_dir() with the application’s name as an argument. This function returns a string representing the path to a directory where you can store configurations.

  • Line 14 defines CONFIG_FILE_PATH to hold the path to the configuration file itself.

  • Line 16 defines init_app(). This function initializes the application’s configuration file and database.

  • Line 18 calls the _init_config_file() helper function, which you define in lines 26 to 35. Calling this function creates the configuration directory using Path.mkdir(). It also creates the configuration file using Path.touch(). Finally, _init_config_file() returns the proper error codes if something wrong happens during the creation of the directory and file. It returns SUCCESS if everything goes okay.

  • Line 19 checks if an error occurs during the creation of the directory and configuration file, and line 20 returns the error code accordingly.

  • Line 21 calls the _create_database() helper function, which creates the to-do database. This function returns the appropriate error codes if something happens while creating the database. It returns SUCCESS if the process succeeds.

  • Line 22 checks if an error occurs during the creation of the database. If so, then line 23 returns the corresponding error code.

  • Line 24 returns SUCCESS if everything runs okay.

With this code, you’ve finished setting up the application’s configuration file to store the path to the to-do database. You’ve also added code to create the to-do database as a JSON file. Now you can write code for initializing the database and getting it ready for use. That’s what you’ll do in the following section.

Get the To-Do Database Ready for Use

To get the to-do database ready for use, you need to perform two actions. First, you need a way to retrieve the database file path from the application’s configuration file. Second, you need to initialize the database to hold JSON content.

Open database.py from rptodo/ in your code editor and write the following code:

 1"""This module provides the RP To-Do database functionality."""
 2# rptodo/database.py
 3
 4import configparser
 5from pathlib import Path
 6
 7from rptodo import DB_WRITE_ERROR, SUCCESS
 8
 9DEFAULT_DB_FILE_PATH = Path.home().joinpath(
10    "." + Path.home().stem + "_todo.json"
11)
12
13def get_database_path(config_file: Path) -> Path:
14    """Return the current path to the to-do database."""
15    config_parser = configparser.ConfigParser()
16    config_parser.read(config_file)
17    return Path(config_parser["General"]["database"])
18
19def init_database(db_path: Path) -> int:
20    """Create the to-do database."""
21    try:
22        db_path.write_text("[]")  # Empty to-do list
23        return SUCCESS
24    except OSError:
25        return DB_WRITE_ERROR

In this file, lines 4 to 7 perform the required imports. Here’s what the rest of the code does:

  • Lines 9 to 11 define DEFAULT_DB_FILE_PATH to hold the default database file path. The application will use this path if the user doesn’t provide a custom one.

  • Lines 13 to 17 define get_database_path(). This function takes the path to the app’s config file as an argument, reads the input file using ConfigParser.read(), and returns a Path object representing the path to the to-do database on your file system. The ConfigParser instance stores the data in a dictionary. The "General" key represents the file section that stores the required information. The "database" key retrieves the database path.

  • Lines 19 to 25 define init_database(). This function takes a database path and writes a string representing an empty list. You call .write_text() on the database path, and the list initializes the JSON database with an empty to-do list. If the process runs successfully, then init_database() returns SUCCESS. Otherwise, it returns the appropriate error code.

Cool! Now you have a way to retrieve the database file path from the application’s configuration file. You also have a way to initialize the database with an empty to-do list in JSON format. It’s time to implement the init command with Typer so that your users can initialize their to-do database from the CLI.

Implement the init CLI Command

The final step to put together all the code you’ve written in this section is to add the init command to your application’s CLI. This command will take an optional database file path. It’ll then create the application’s configuration file and to-do database.

Go ahead and add init() to your cli.py file:

 1"""This module provides the RP To-Do CLI."""
 2# rptodo/cli.py
 3
 4from pathlib import Path
 5from typing import Optional
 6
 7import typer
 8
 9from rptodo import ERRORS, __app_name__, __version__, config, database
10
11app = typer.Typer()
12
13@app.command()
14def init(
15    db_path: str = typer.Option(
16        str(database.DEFAULT_DB_FILE_PATH),
17        "--db-path",
18        "-db",
19        prompt="to-do database location?",
20    ),
21) -> None:
22    """Initialize the to-do database."""
23    app_init_error = config.init_app(db_path)
24    if app_init_error:
25        typer.secho(
26            f'Creating config file failed with "{ERRORS[app_init_error]}"',
27            fg=typer.colors.RED,
28        )
29        raise typer.Exit(1)
30    db_init_error = database.init_database(Path(db_path))
31    if db_init_error:
32        typer.secho(
33            f'Creating database failed with "{ERRORS[db_init_error]}"',
34            fg=typer.colors.RED,
35        )
36        raise typer.Exit(1)
37    else:
38        typer.secho(f"The to-do database is {db_path}", fg=typer.colors.GREEN)
39
40def _version_callback(value: bool) -> None:
41    # ...

Here’s how this new code works:

  • Lines 4 and 9 update the required imports.

  • Lines 13 and 14 define init() as a Typer command using the @app.command() decorator.

  • Lines 15 to 20 define a Typer Option instance and assign it as a default value to db_path. To provide a value for this option, your users need to use --db-path or -db followed by a database path. The prompt argument displays a prompt asking for a database location. It also allows you to accept the default path by pressing Enter.

  • Line 23 calls init_app() to create the application’s configuration file and to-do database.

  • Lines 24 to 29 check if the call to init_app() returns an error. If so, lines 25 to 28 print an error message. Line 29 exits the app with a typer.Exit exception and an exit code of 1 to signal that the application terminated with an error.

  • Line 30 calls init_database() to initialize the database with an empty to-do list.

  • Lines 31 to 38 check if the call to init_database() returns an error. If so, then lines 32 to 35 display an error message, and line 36 exits the application. Otherwise, line 38 prints a success message in green text.

To print the messages in this code, you use typer.secho(). This function takes a foreground argument, fg, that allows you to use different colors when printing text to the screen. Typer provides several built-in colors in typer.colors. There you’ll find RED, BLUE, GREEN, and more. You can use those colors with secho() as you did here.

Nice! With all this code in place, you can now give the init command a try. Go back to your terminal and run the following:

(venv) $ python -m rptodo init
to-do database location? [/home/user/.user_todo.json]:
The to-do database is /home/user/.user_todo.json

This command presents you with a prompt for entering a database location. You can press Enter to accept the default path in square brackets, or you can type in a custom path and then press Enter. The application creates the to-do database and tells you where it’ll reside from this point on.

Alternatively, you can provide a custom database path directly by using init with the -db or --db-path options followed by the desired path. In all cases, your custom path should include the database file name.

Once you’ve run the above command, take a look at your home directory. You’ll have a JSON file named after the filename you used with init. You’ll also have a rptodo/ directory containing a config.ini file somewhere in your home folder. The specific path to this file will depend on your current operating system. On Ubuntu, for example, the file will be at /home/user/.config/rptodo/.

Step 4: Set Up the To-Do App Back End

Up to this point, you’ve made a way to create, initialize, and connect to the to-do database. Now you can start thinking of your data model. In other words, you need to think about how to represent and store data about your to-dos. You also need to define how your application will handle communication between the CLI and database.

You can download the code and all the additional resources you’ll use in this section by clicking the link below and going to the source_code_step_4/ directory:

Define a Single To-Do

First, think about the data you need for defining a single to-do. In this project, a to-do will consist of the following pieces of information:

  • Description: How do you describe this to-do?
  • Priority: What priority does this to-do have over the rest of your to-dos?
  • Done: Is this to-do done?

To store this information, you can use a regular Python dictionary:

todo = {
    "Description": "Get some milk.",
    "Priority": 2,
    "Done": True,
}

The "Description" key stores a string describing the current to-do. The "Priority" key can take three possible values: 1 for high, 2 for medium, and 3 for low priority. The "Done" key holds True when you’ve completed the to-do and False otherwise.

Communicate With the CLI

To communicate with the CLI, you’ll use two pieces of data holding the required information:

  1. todo: The dictionary holding the information for the current to-do
  2. error: The return or error code confirming if the current operation was successful or not

To store this data, you’ll use a named tuple with appropriately named fields. Open up the rptodo.py module from rptodo to create the required named tuple:

 1"""This module provides the RP To-Do model-controller."""
 2# rptodo/rptodo.py
 3
 4from typing import Any, Dict, NamedTuple
 5
 6class CurrentTodo(NamedTuple):
 7    todo: Dict[str, Any]
 8    error: int

In rptodo.py, you first import some required objects from typing. On line 6, you create a subclass of typing.NamedTuple called CurrentTodo with two fields todo and error.

Subclassing NamedTuple allows you to create named tuples with type hints for their named fields. For example, the todo field above holds a dictionary with keys of type str and values of type Any. The error field holds an int value.

Communicate With the Database

Now you need another data container that allows you to send data to and retrieve data from the to-do database. In this case, you’ll use another named tuple with the following fields:

  1. todo_list: The to-do list you’ll write to and read from the database
  2. error: An integer number representing a return code related to the current database operation

Finally, you’ll create a class called DatabaseHandler to read and write data to the to-do database. Go ahead and open database.py. Once you’re there, type in the following code:

 1# rptodo/database.py
 2
 3import configparser
 4import json
 5from pathlib import Path
 6from typing import Any, Dict, List, NamedTuple
 7
 8from rptodo import DB_READ_ERROR, DB_WRITE_ERROR, JSON_ERROR, SUCCESS
 9
10# ...
11
12class DBResponse(NamedTuple):
13    todo_list: List[Dict[str, Any]]
14    error: int
15
16class DatabaseHandler:
17    def __init__(self, db_path: Path) -> None:
18        self._db_path = db_path
19
20    def read_todos(self) -> DBResponse:
21        try:
22            with self._db_path.open("r") as db:
23                try:
24                    return DBResponse(json.load(db), SUCCESS)
25                except json.JSONDecodeError:  # Catch wrong JSON format
26                    return DBResponse([], JSON_ERROR)
27        except OSError:  # Catch file IO problems
28            return DBResponse([], DB_READ_ERROR)
29
30    def write_todos(self, todo_list: List[Dict[str, Any]]) -> DBResponse:
31        try:
32            with self._db_path.open("w") as db:
33                json.dump(todo_list, db, indent=4)
34            return DBResponse(todo_list, SUCCESS)
35        except OSError:  # Catch file IO problems
36            return DBResponse(todo_list, DB_WRITE_ERROR)

Here’s what this code does:

  • Lines 4, 6, and 8 add some required imports.

  • Lines 12 to 14 define DBResponse as a NamedTuple subclass. The todo_list field is a list of dictionaries representing individual to-dos, while the error field holds an integer return code.

  • Line 16 defines DatabaseHandler, which allows you to read and write data to the to-do database using the json module from the standard library.

  • Lines 17 and 18 define the class initializer, which takes a single argument representing the path to the database on your file system.

  • Line 20 defines .read_todos(). This method reads the to-do list from the database and deserializes it.

  • Line 21 starts a tryexcept statement to catch any errors that occur while you’re opening the database. If an error occurs, then line 28 returns a DBResponse instance with an empty to-do list and a DB_READ_ERROR.

  • Line 22 opens the database for reading using a with statement.

  • Line 23 starts another tryexcept statement to catch any errors that occur while you’re loading and deserializing the JSON content from the to-do database.

  • Line 24 returns a DBResponse instance holding the result of calling json.load() with the to-do database object as an argument. This result consists of a list of dictionaries. Every dictionary represents a to-do. The error field of DBResponse holds SUCCESS to signal that the operation was successful.

  • Line 25 catches any JSONDecodeError while loading the JSON content from the database, and line 26 returns with an empty list and a JSON_ERROR.

  • Line 27 catches any file IO problems while loading the JSON file, and line 28 returns a DBResponse instance with an empty to-do list and a DB_READ_ERROR.

  • Line 30 defines .write_todos(), which takes a list of to-do dictionaries and writes it to the database.

  • Line 31 starts a tryexcept statement to catch any errors that occur while you’re opening the database. If an error occurs, then line 36 returns a DBResponse instance with the original to-do list and a DB_READ_ERROR.

  • Line 32 uses a with statement to open the database for writing.

  • Line 33 dumps the to-do list as a JSON payload into the database.

  • Line 34 returns a DBResponse instance holding the to-do list and the SUCCESS code.

Wow! That was a lot! Now that you finished coding DatabaseHandler and setting up the data exchange mechanism, you can think about how to connect them to the application’s CLI.

Write the Controller Class, Todoer

To connect the DatabaseHandler logic with your application’s CLI, you’ll write a class called Todoer. This class will work similarly to a controller in the Model-View-Controller pattern.

Now go back to rptodo.py and add the following code:

# rptodo/rptodo.py
from pathlib import Path
from typing import Any, Dict, NamedTuple

from rptodo.database import DatabaseHandler

# ...

class Todoer:
    def __init__(self, db_path: Path) -> None:
        self._db_handler = DatabaseHandler(db_path)

This code includes some imports and the definition of Todoer. This class uses composition, so it has a DatabaseHandler component to facilitate direct communication with the to-do database. You’ll add more code to this class in upcoming sections.

In this section, you’ve put together a lot of setups that shape how your to-do application’s back end will work. You’ve decided what data structures to use for storing the to-do data. You’ve also defined what kind of database you’ll use to save the to-do information and how to operate on it.

With all those setups in place, you’re now ready to start providing value to your users by allowing them to populate their to-do lists. You’ll also implement a way to display the to-dos on the screen.

Step 5: Code the Adding and Listing To-Dos Functionalities

In this section, you’ll code one of the main features of your to-do application. You’ll provide your users with a command to add new to-dos to their current list. You’ll also allow the users to list their to-dos on the screen in a tabular format.

Before working on these features, you’ll set up a minimal test suite for your code. Writing a test suite before writing the code will help you understand what test-driven development (TDD) is about.

To download the code, unit tests, and all the additional resources you’ll add in this section, just click the link below and go to the source_code_step_5/ directory:

Define Unit Tests for Todoer.add()

In this section, you’ll use pytest to write and run a minimal test suite for Todoer.add(). This method will take care of adding new to-dos to the database. With the test suite in place, you’ll write the required code to pass the tests, which is a fundamental idea behind TDD.

Before writing tests for .add(), think of what this method needs to do:

  1. Get a to-do description and priority
  2. Create a dictionary to hold the to-do information
  3. Read the to-do list from the database
  4. Append the new to-do to the current to-do list
  5. Write the updated to-do list back to the database
  6. Return the newly added to-do along with a return code back to the caller

A common practice in code testing is to start with the main functionality of a given method or function. You’ll start by creating test cases to check if .add() properly adds new to-dos to the database.

To test .add(), you must create a Todoer instance with a proper JSON file as the target database. To provide that file, you’ll use a pytest fixture.

Go back to your code editor and open test_rptodo.py from the tests/ directory. Add the following code to it:

# tests/test_rptodo.py
import json

import pytest
from typer.testing import CliRunner

from rptodo import (
    DB_READ_ERROR,
    SUCCESS,
    __app_name__,
    __version__,
    cli,
    rptodo,
)

# ...

@pytest.fixture
def mock_json_file(tmp_path):
    todo = [{"Description": "Get some milk.", "Priority": 2, "Done": False}]
    db_file = tmp_path / "todo.json"
    with db_file.open("w") as db:
        json.dump(todo, db, indent=4)
    return db_file

Here, you first update your imports to complete some requirements. The fixture, mock_json_file(), creates and returns a temporary JSON file, db_file, with a single-item to-do list in it. In this fixture, you use tmp_path, which is a pathlib.Path object that pytest uses to provide a temporary directory for testing purposes.

You already have a temporary to-do database to use. Now you need some data to create your test cases:

# tests/test_rptodo.py
# ...

test_data1 = {
    "description": ["Clean", "the", "house"],
    "priority": 1,
    "todo": {
        "Description": "Clean the house.",
        "Priority": 1,
        "Done": False,
    },
}
test_data2 = {
    "description": ["Wash the car"],
    "priority": 2,
    "todo": {
        "Description": "Wash the car.",
        "Priority": 2,
        "Done": False,
    },
}

These two dictionaries provide data to test Todoer.add(). The first two keys represent the data you’ll use as arguments to .add(), while the third key holds the expected return value of the method.

Now it’s time to write your first test function for .add(). With pytest, you can use parametrization to provide multiple sets of arguments and expected results to a single test function. This is a pretty neat feature. It makes a single test function behave like several test functions that run different test cases.

Here’s how you can create your test function using parametrization in pytest:

 1# tests/test_rptodo.py
 2# ...
 3
 4@pytest.mark.parametrize(
 5    "description, priority, expected",
 6    [
 7        pytest.param(
 8            test_data1["description"],
 9            test_data1["priority"],
10            (test_data1["todo"], SUCCESS),
11        ),
12        pytest.param(
13            test_data2["description"],
14            test_data2["priority"],
15            (test_data2["todo"], SUCCESS),
16        ),
17    ],
18)
19def test_add(mock_json_file, description, priority, expected):
20    todoer = rptodo.Todoer(mock_json_file)
21    assert todoer.add(description, priority) == expected
22    read = todoer._db_handler.read_todos()
23    assert len(read.todo_list) == 2

The @pytest.mark.parametrize() decorator marks test_add() for parametrization. When pytest runs this test, it calls test_add() two times. Each call uses one of the parameter sets from lines 7 to 11 and lines 12 to 16.

The string on line 5 holds descriptive names for the two required parameters and also a descriptive return value name. Note that test_add() has those same parameters. Additionally, the first parameter of test_add() has the same name as the fixture you just defined.

Inside test_add(), the code does the following actions:

  • Line 20 creates an instance of Todoer with mock_json_file as an argument.

  • Line 21 asserts that a call to .add() using description and priority as arguments should return expected.

  • Line 22 reads the to-do list from the temporary database and stores it in read.

  • Line 23 asserts that the length of the to-do list is 2. Why 2? Because mock_json_file() returns a list with one to-do, and now you’re adding a second one.

Cool! You have a test that covers the main functionality of .add(). Now it’s time to run your test suite again. Go back to your command line and run python -m pytest tests/. You’ll get an output similar to the following:

======================== test session starts ==========================
platform linux -- Python 3.8.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: .../rptodo
plugins: Faker-8.1.1, cov-2.12.0, celery-4.4.7
collected 3 items

tests/test_rptodo.py .FF                                        [100%]
============================== FAILURES ===============================

# Output cropped

The F letters in the highlighted line mean that two of your test cases have failed. Having failing tests is the first step for TDD. The second step is to write code for passing those tests. That’s what you’re going to do next.

Implement the add CLI Command

In this section, you’ll code .add() in the Todoer class. You’ll also code the add command in your Typer CLI. With these two pieces of code in place, your users will be able to add new items to their to-dos lists.

Every time the to-do application runs, it needs to access the Todoer class and connect the CLI with the database. To satisfy this requirement, you’ll implement a function called get_todoer().

Go back to your code editor and open cli.py. Type in the following code:

 1# rptodo/cli.py
 2
 3from pathlib import Path
 4from typing import List, Optional
 5
 6import typer
 7
 8from rptodo import (
 9    ERRORS, __app_name__, __version__, config, database, rptodo
10)
11
12app = typer.Typer()
13
14@app.command()
15def init(
16    # ...
17
18def get_todoer() -> rptodo.Todoer:
19    if config.CONFIG_FILE_PATH.exists():
20        db_path = database.get_database_path(config.CONFIG_FILE_PATH)
21    else:
22        typer.secho(
23            'Config file not found. Please, run "rptodo init"',
24            fg=typer.colors.RED,
25        )
26        raise typer.Exit(1)
27    if db_path.exists():
28        return rptodo.Todoer(db_path)
29    else:
30        typer.secho(
31            'Database not found. Please, run "rptodo init"',
32            fg=typer.colors.RED,
33        )
34        raise typer.Exit(1)
35
36def _version_callback(value: bool) -> None:
37    # ...

After updating the imports, you define get_todoer() on line 18. Line 19 defines a conditional that checks if the application’s configuration file exists. To do so, it uses Path.exists().

If the configuration file exists, then line 20 gets the path to the database from it. The else clause runs if the file doesn’t exist. This clause prints an error message to the screen and exits the application with an exit code of 1 to signal an error.

Line 27 checks if the path to the database exists. If so, then line 28 creates an instance of Todoer with the path as an argument. Otherwise, the else clause that starts on line 29 prints an error message and exits the application.

Now that you have an instance of Todoer with a valid database path, you can write .add(). Go back to the rptodo.py module and update Todoer:

 1# rptodo/rptodo.py
 2from pathlib import Path
 3from typing import Any, Dict, List, NamedTuple
 4
 5from rptodo import DB_READ_ERROR
 6from rptodo.database import DatabaseHandler
 7
 8# ...
 9
10class Todoer:
11    def __init__(self, db_path: Path) -> None:
12        self._db_handler = DatabaseHandler(db_path)
13
14    def add(self, description: List[str], priority: int = 2) -> CurrentTodo:
15        """Add a new to-do to the database."""
16        description_text = " ".join(description)
17        if not description_text.endswith("."):
18            description_text += "."
19        todo = {
20            "Description": description_text,
21            "Priority": priority,
22            "Done": False,
23        }
24        read = self._db_handler.read_todos()
25        if read.error == DB_READ_ERROR:
26            return CurrentTodo(todo, read.error)
27        read.todo_list.append(todo)
28        write = self._db_handler.write_todos(read.todo_list)
29        return CurrentTodo(todo, write.error)

Here’s how .add() works line by line:

  • Line 14 defines .add(), which takes description and priority as arguments. The description is a list of strings. Typer builds this list from the words you enter at the command line to describe the current to-do. In the case of priority, it’s an integer value representing the to-do’s priority. The default is 2, indicating a medium priority.

  • Line 16 concatenates the description components into a single string using .join().

  • Lines 17 and 18 add a period (".") to the end of the description if the user doesn’t add it.

  • Lines 19 to 23 build a new to-do from the user’s input.

  • Line 24 reads the to-do list from the database by calling .read_todos() on the database handler.

  • Line 25 checks if .read_todos() returned a DB_READ_ERROR. If so, then line 26 returns a named tuple, CurrentTodo, containing the current to-do and the error code.

  • Line 27 appends the new to-do to the list.

  • Line 28 writes the updated to-do list back to the database by calling .write_todos() on the database handler.

  • Line 29 returns an instance of CurrentTodo with the current to-do and an appropriate return code.

Now you can run your test suite again to check if .add() works correctly. Go ahead and run python -m pytest tests/. You’ll get an output similar to the following:

========================= test session starts =========================
platform linux -- Python 3.9.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
plugins: Faker-8.1.1, cov-2.12.0, celery-4.4.7
rootdir: .../rptodo
collected 2 items

tests/test_rptodo.py ...                                        [100%]
========================== 3 passed in 0.09s ==========================

The three green dots mean that you have three passing tests. If you downloaded the code from the project’s repo on GitHub, then you get an output with a few more successful tests.

Once you’ve finished writing .add(), you can head to cli.py and write the add command for your application’s CLI:

 1# rptodo/cli.py
 2# ...
 3
 4def get_todoer() -> rptodo.Todoer:
 5    # ...
 6
 7@app.command()
 8def add(
 9    description: List[str] = typer.Argument(...),
10    priority: int = typer.Option(2, "--priority", "-p", min=1, max=3),
11) -> None:
12    """Add a new to-do with a DESCRIPTION."""
13    todoer = get_todoer()
14    todo, error = todoer.add(description, priority)
15    if error:
16        typer.secho(
17            f'Adding to-do failed with "{ERRORS[error]}"', fg=typer.colors.RED
18        )
19        raise typer.Exit(1)
20    else:
21        typer.secho(
22            f"""to-do: "{todo['Description']}" was added """
23            f"""with priority: {priority}""",
24            fg=typer.colors.GREEN,
25        )
26
27def _version_callback(value: bool) -> None:
28    # ...

Here’s a breakdown of what the add command does:

  • Lines 7 and 8 define add() as a Typer command using the @app.command() Python decorator.

  • Line 9 defines description as an argument to add(). This argument holds a list of strings representing a to-do description. To build the argument, you use typer.Argument. When you pass an ellipsis (...) as the first argument to the constructor of Argument, you’re telling Typer that description is required. The fact that this argument is required means that the user must provide a to-do description at the command line.

  • Line 10 defines priority as a Typer option with a default value of 2. The option names are --priority and -p. As you decided earlier, priority only accepts three possible values: 1, 2, or 3. To guarantee this condition, you set min to 1 and max to 3. This way, Typer automatically validates the user’s input and only accepts numbers within the specified interval.

  • Line 13 gets a Todoer instance to use.

  • Line 14 calls .add() on todoer and unpacks the result into todo and error.

  • Lines 15 to 25 define a conditional statement that prints an error message and exits the application if an error occurs while adding the new to-do to the database. If no error happens, then the else clause on line 20 displays a success message on the screen.

Now you can go back to your terminal and give your add command a try:

(venv) $ python -m rptodo add Get some milk -p 1
to-do: "Get some milk." was added with priority: 1

(venv) $ python -m rptodo add Clean the house --priority 3
to-do: "Clean the house." was added with priority: 3

(venv) $ python -m rptodo add Wash the car
to-do: "Wash the car." was added with priority: 2

(venv) $ python -m rptodo add Go for a walk -p 5
Usage: rptodo add [OPTIONS] DESCRIPTION...
Try 'rptodo add --help' for help.

Error: Invalid value for '--priority' / '-p': 5 is not in the valid range...

In the first example, you execute the add command with the description "Get some milk" and a priority of 1. To set the priority, you use the -p option. Once you press Enter, the application adds the to-do and informs you about the successful addition. The second example works pretty similarly. This time you use --priority to set the to-do priority to 3.

In the third example, you provide a to-do description without supplying a priority. In this situation, the app uses the default priority value, which is 2.

In the fourth example, you try to add a new to-do with a priority of 5. Since this priority value is out of the allowed range, Typer displays a usage message along with an error message. Note that Typer automatically displays these messages for you. You don’t need to add extra code for this to happen.

Great! Your to-do application already has some cool functionality. Now you need a way to list all your to-dos to get an idea of how much work you have on your plate. In the following section, you’ll implement the list command to help you out with this task.

Implement the list Command

In this section, you’ll add the list command to your application’s CLI. This command will allow your users to list all their current to-dos. Before adding any code to your CLI, you need a way to retrieve the entire to-do list from the database. To accomplish this task, you’ll add .get_todo_list() to the Todoer class.

Open up rptodo.py in your code editor or IDE and add the following code:

# rptodo/rptodo.py
# ...

class Todoer:
    # ...
    def get_todo_list(self) -> List[Dict[str, Any]]:
        """Return the current to-do list."""
        read = self._db_handler.read_todos()
        return read.todo_list

Inside .get_todo_list(), you first get the entire to-do list from the database by calling .read_todos() on the database handler. The call to .read_todos() returns a named tuple, DBResponse, containing the to-do list and a return code. However, you just need the to-do list, so .get_todo_list() returns the .todo_list field only.

With .get_todo_list() in place, you can now implement the list command in the application’s CLI. Go ahead and add list_all() to cli.py:

 1# rptodo/cli.py
 2# ...
 3
 4@app.command()
 5def add(
 6    # ...
 7
 8@app.command(name="list")
 9def list_all() -> None:
10    """List all to-dos."""
11    todoer = get_todoer()
12    todo_list = todoer.get_todo_list()
13    if len(todo_list) == 0:
14        typer.secho(
15            "There are no tasks in the to-do list yet", fg=typer.colors.RED
16        )
17        raise typer.Exit()
18    typer.secho("nto-do list:n", fg=typer.colors.BLUE, bold=True)
19    columns = (
20        "ID.  ",
21        "| Priority  ",
22        "| Done  ",
23        "| Description  ",
24    )
25    headers = "".join(columns)
26    typer.secho(headers, fg=typer.colors.BLUE, bold=True)
27    typer.secho("-" * len(headers), fg=typer.colors.BLUE)
28    for id, todo in enumerate(todo_list, 1):
29        desc, priority, done = todo.values()
30        typer.secho(
31            f"{id}{(len(columns[0]) - len(str(id))) * ' '}"
32            f"| ({priority}){(len(columns[1]) - len(str(priority)) - 4) * ' '}"
33            f"| {done}{(len(columns[2]) - len(str(done)) - 2) * ' '}"
34            f"| {desc}",
35            fg=typer.colors.BLUE,
36        )
37    typer.secho("-" * len(headers) + "n", fg=typer.colors.BLUE)
38
39def _version_callback(value: bool) -> None:
40    # ...

Here’s how list_all() works:

  • Lines 8 and 9 define list_all() as a Typer command using the @app.command() decorator. The name argument to this decorator sets a custom name for the command, which is list here. Note that list_all() doesn’t take any argument or option. It just lists the to-dos when the user runs list from the command line.

  • Line 11 gets the Todoer instance that you’ll use.

  • Line 12 gets the to-do list from the database by calling .get_todo_list() on todoer.

  • Lines 13 to 17 define a conditional statement to check if there’s at least one to-do in the list. If not, then the if code block prints an error message to the screen and exits the application.

  • Line 18 prints a top-level header to present the to-do list. In this case, secho() takes an additional Boolean argument called bold, which enables you to display text in a bolded font format.

  • Lines 19 to 27 define and print the required columns to display the to-do list in a tabular format.

  • Lines 28 to 36 run a for loop to print every single to-do on its own row with appropriate padding and separators.

  • Line 37 prints a line of dashes with a final line feed character (n) to visually separate the to-do list from the next command-line prompt.

If you run the application with the list command, then you get the following output:

(venv) $ python -m rptodo list

to-do list:

ID.  | Priority  | Done  | Description
----------------------------------------
1    | (1)       | False | Get some milk.
2    | (3)       | False | Clean the house.
3    | (2)       | False | Wash the car.
----------------------------------------

This output shows all the current to-dos in a nicely formatted table. This way, your user can track the state of their list of tasks. Note that the output should display in blue font on your terminal window.

Step 6: Code the To-Do Completion Functionality

The next feature you’ll add to your to-do application is a Typer command that allows your users to set a given to-do as complete. This way, your users can track their progress and know how much work is left.

Again, you can download the code and all the resources for this section, including additional unit tests, by clicking the link below and going to the source_code_step_6/ directory:

As usual, you’ll start by coding the required functionality back in Todoer. In this case, you need a method that takes a to-do ID and marks the corresponding to-do as done. Go back to rptodo.py in your code editor and add the following code:

 1# rptodo/rptodo.py
 2# ...
 3from rptodo import DB_READ_ERROR, ID_ERROR
 4from rptodo.database import DatabaseHandler
 5
 6# ...
 7
 8class Todoer:
 9    # ...
10    def set_done(self, todo_id: int) -> CurrentTodo:
11        """Set a to-do as done."""
12        read = self._db_handler.read_todos()
13        if read.error:
14            return CurrentTodo({}, read.error)
15        try:
16            todo = read.todo_list[todo_id - 1]
17        except IndexError:
18            return CurrentTodo({}, ID_ERROR)
19        todo["Done"] = True
20        write = self._db_handler.write_todos(read.todo_list)
21        return CurrentTodo(todo, write.error)

Your new .set_done() method does the required job. Here’s how:

  • Line 10 defines .set_done(). The method takes an argument called todo_id, which holds an integer representing the ID of the to-do you want to mark as done. The to-do ID is the number associated with a given to-do when you list your to-dos using the list command. Since you’re using a Python list to store the to-dos, you can turn this ID into a zero-based index and use it to retrieve the required to-do from the list.

  • Line 12 reads all the to-dos by calling .read_todos() on the database handler.

  • Line 13 checks if any error occurs during the reading. If so, then line 14 returns a named tuple, CurrentTodo, with an empty to-do and the error.

  • Line 15 starts a tryexcept statement to catch invalid to-do IDs that translate to invalid indices in the underlying to-do list. If an IndexError occurs, then line 18 returns a CurrentTodo instance with an empty to-do and the corresponding error code.

  • Line 19 assigns True to the "Done" key in the target to-do dictionary. This way, you’re setting the to-do as done.

  • Line 20 writes the update back to the database by calling .write_todos() on the database handler.

  • Line 21 returns a CurrentTodo instance with the target to-do and a return code indicating how the operation went.

With .set_done() in place, you can move to cli.py and write the complete command. Here’s the required code:

 1# rptodo/cli.py
 2# ...
 3
 4@app.command(name="list")
 5def list_all() -> None:
 6    # ...
 7
 8@app.command(name="complete")
 9def set_done(todo_id: int = typer.Argument(...)) -> None:
10    """Complete a to-do by setting it as done using its TODO_ID."""
11    todoer = get_todoer()
12    todo, error = todoer.set_done(todo_id)
13    if error:
14        typer.secho(
15            f'Completing to-do # "{todo_id}" failed with "{ERRORS[error]}"',
16            fg=typer.colors.RED,
17        )
18        raise typer.Exit(1)
19    else:
20        typer.secho(
21            f"""to-do # {todo_id} "{todo['Description']}" completed!""",
22            fg=typer.colors.GREEN,
23        )
24
25def _version_callback(value: bool) -> None:
26    # ...

Take a look at how this code works line by line:

  • Lines 8 and 9 define set_done() as a Typer command with the usual @app.command() decorator. In this case, you use complete for the command name. The set_done() function takes an argument called todo_id, which defaults to an instance of typer.Argument. This instance will work as a required command-line argument.

  • Line 11 gets the usual Todoer instance.

  • Line 12 sets the to-do with the specific todo_id as done by calling .set_done() on todoer.

  • Line 13 checks if any error occurs during the process. If so, then lines 14 to 18 print an appropriate error message and exit the application with an exit code of 1. If no error occurs, then lines 20 to 23 print a success message in green font.

That’s it! Now you can give your new complete command a try. Back in your terminal window, run the following commands:

(venv) $ python -m rptodo list

to-do list:

ID.  | Priority  | Done  | Description
----------------------------------------
1    | (1)       | False | Get some milk.
2    | (3)       | False | Clean the house.
3    | (2)       | False | Wash the car.
----------------------------------------

(venv) $ python -m rptodo complete 1
to-do # 1 "Get some milk." completed!

(venv) $ python -m rptodo list

to-do list:

ID.  | Priority  | Done  | Description
----------------------------------------
1    | (1)       | True  | Get some milk.
2    | (3)       | False | Clean the house.
3    | (2)       | False | Wash the car.
----------------------------------------

First, you list all your to-dos to visualize the ID that corresponds to each of them. Then you use complete to set the to-do with an ID of 1 as done. When you list the to-dos again, you see that the first to-do is now marked as True in the Done column.

An important detail to note about the complete command and the underlying Todoer.set_done() method is that the to-do ID is not a fixed value. If you remove one or more to-dos from the list, then the IDs of some remaining to-dos will change. Speaking of removing to-dos, that’s what you’ll do in the following section.

Step 7: Code the Remove To-Dos Functionality

Removing to-dos from the list is another useful feature you can add to your to-do application. In this section, you’ll add two new Typer commands to the app’s CLI using Python. The first command will be remove. It’ll allow your users to remove a to-do by its ID. The second command will be clear and will enable the users to remove all the current to-dos from the database.

You can download the code, unit tests, and other additional resources for this section by clicking the link below and going to the source_code_step_7/ directory:

Implement the remove CLI Command

To implement the remove command in your application’s CLI, you first need to code the underlying .remove() method in Todoer. This method will provide all the functionality to remove a single to-do from the list using the to-do ID. Remember that you set up the to-do ID to be an integer number associated with a specific to-do. To display the to-do IDs, run the list command.

Here’s how you can code .remove() in Todoer:

 1# rptodo/rptodo.py
 2# ...
 3
 4class Todoer:
 5    # ...
 6    def remove(self, todo_id: int) -> CurrentTodo:
 7        """Remove a to-do from the database using its id or index."""
 8        read = self._db_handler.read_todos()
 9        if read.error:
10            return CurrentTodo({}, read.error)
11        try:
12            todo = read.todo_list.pop(todo_id - 1)
13        except IndexError:
14            return CurrentTodo({}, ID_ERROR)
15        write = self._db_handler.write_todos(read.todo_list)
16        return CurrentTodo(todo, write.error)

Here, your code does the following:

  • Line 6 defines .remove(). This method takes a to-do ID as an argument and removes the corresponding to-do from the database.

  • Line 8 reads the to-do list from the database by calling .read_todos() on the database handler.

  • Line 9 checks if any error occurs during the reading process. If so, then line 10 returns a named tuple, CurrentTodo, holding an empty to-do and the corresponding error code.

  • Line 11 starts a tryexcept statement to catch any invalid ID coming from the user’s input.

  • Line 12 removes the to-do at index todo_id - 1 from the to-do list. If an IndexError occurs during this operation, then line 14 returns a CurrentTodo instance with an empty to-do and the corresponding error code.

  • Line 15 writes the updated to-do list back to the database.

  • Line 16 returns a CurrentTodo tuple holding the removed to-do and a return code indicating a successful operation.

Now that you finished coding .remove() in Todoer, you can go to cli.py and add the remove command:

 1# rptodo/cli.py
 2# ...
 3
 4@app.command()
 5def set_done(todo_id: int = typer.Argument(...)) -> None:
 6    # ...
 7
 8@app.command()
 9def remove(
10    todo_id: int = typer.Argument(...),
11    force: bool = typer.Option(
12        False,
13        "--force",
14        "-f",
15        help="Force deletion without confirmation.",
16    ),
17) -> None:
18    """Remove a to-do using its TODO_ID."""
19    todoer = get_todoer()
20
21    def _remove():
22        todo, error = todoer.remove(todo_id)
23        if error:
24            typer.secho(
25                f'Removing to-do # {todo_id} failed with "{ERRORS[error]}"',
26                fg=typer.colors.RED,
27            )
28            raise typer.Exit(1)
29        else:
30            typer.secho(
31                f"""to-do # {todo_id}: '{todo["Description"]}' was removed""",
32                fg=typer.colors.GREEN,
33            )
34
35    if force:
36        _remove()
37    else:
38        todo_list = todoer.get_todo_list()
39        try:
40            todo = todo_list[todo_id - 1]
41        except IndexError:
42            typer.secho("Invalid TODO_ID", fg=typer.colors.RED)
43            raise typer.Exit(1)
44        delete = typer.confirm(
45            f"Delete to-do # {todo_id}: {todo['Description']}?"
46        )
47        if delete:
48            _remove()
49        else:
50            typer.echo("Operation canceled")
51
52def _version_callback(value: bool) -> None:
53    # ...

Wow! That’s a lot of code. Here’s how it works:

  • Lines 8 and 9 define remove() as a Typer CLI command.

  • Line 10 defines todo_id as an argument of type int. In this case, todo_id is a required instance of typer.Argument.

  • Line 11 defines force as an option for the remove command. It’s a Boolean option that allows you to delete a to-do without confirmation. This option defaults to False (line 12) and its flags are --force and -f (lines 13 and 14).

  • Line 15 defines a help message for the force option.

  • Line 19 creates the required Todoer instance.

  • Lines 21 to 33 define an inner function called _remove(). It’s a helper function that allows you to reuse the remove functionality. The function removes a to-do using its ID. To do that, it calls .remove() on todoer.

  • Line 35 checks the value of force. A True value means that the user wants to remove the to-do without confirmation. In this situation, line 36 calls _remove() to run the remove operation.

  • Line 37 starts an else clause that runs if force is False.

  • Line 38 gets the entire to-do list from the database.

  • Lines 39 to 43 define a tryexcept statement that retrieves the desired to-do from the list. If an IndexError occurs, then line 42 prints an error message, and line 43 exits the application.

  • Lines 44 to 46 call Typer’s confirm() and store the result in delete. This function provides an alternative way to ask for confirmation. It allows you to use a dynamically created confirmation prompt like the one on line 45.

  • Line 47 checks if delete is True, in which case line 48 calls _remove(). Otherwise, line 50 communicates that the operation was canceled.

You can try out the remove command by running the following on your command line:

(venv) $ python -m rptodo list

to-do list:

ID.  | Priority  | Done  | Description
----------------------------------------
1    | (1)       | True  | Get some milk.
2    | (3)       | False | Clean the house.
3    | (2)       | False | Wash the car.
----------------------------------------

(venv) $ python -m rptodo remove 1
Delete to-do # 1: Get some milk.? [y/N]:
Operation canceled

(venv) $ python -m rptodo remove 1
Delete to-do # 1: Get some milk.? [y/N]: y
to-do # 1: 'Get some milk.' was removed

(venv) $ python -m rptodo list

to-do list:

ID.  | Priority  | Done  | Description
----------------------------------------
1    | (3)       | False | Clean the house.
2    | (2)       | False | Wash the car.
----------------------------------------

In this group of commands, you first list all the current to-dos with the list command. Then you try to remove the to-do with the ID number 1. This presents you with a yes (y) or no (N) confirmation prompt. If you press Enter, then the application runs the default option, N, and cancels the remove action.

In the third command, you explicitly supply a y answer, so the application removes the to-do with ID number 1. If you list all the to-dos again, then you see that the to-do "Get some milk." is no longer in the list. As an experiment, go ahead and try to use the --force or -f option or try to remove a to-do that’s not in the list.

Implement the clear CLI Command

In this section, you’ll implement the clear command. This command will allow your users to remove all the to-dos from the database. Underneath the clear command is the .remove_all() method from Todoer, which provides the back-end functionality.

Go back to rptodo.py and add .remove_all() at the end of Todoer:

# rptodo/rptodo.py
# ...

class Todoer:
    # ...
    def remove_all(self) -> CurrentTodo:
        """Remove all to-dos from the database."""
        write = self._db_handler.write_todos([])
        return CurrentTodo({}, write.error)

Inside .remove_all(), you remove all the to-dos from the database by replacing the current to-do list with an empty list. For consistency, the method returns a CurrentTodo tuple with an empty dictionary and an appropriate return or error code.

Now you can implement the clear command in the application’s CLI:

 1# rptodo/cli.py
 2# ...
 3
 4@app.command()
 5def remove(
 6    # ...
 7
 8@app.command(name="clear")
 9def remove_all(
10    force: bool = typer.Option(
11        ...,
12        prompt="Delete all to-dos?",
13        help="Force deletion without confirmation.",
14    ),
15) -> None:
16    """Remove all to-dos."""
17    todoer = get_todoer()
18    if force:
19        error = todoer.remove_all().error
20        if error:
21            typer.secho(
22                f'Removing to-dos failed with "{ERRORS[error]}"',
23                fg=typer.colors.RED,
24            )
25            raise typer.Exit(1)
26        else:
27            typer.secho("All to-dos were removed", fg=typer.colors.GREEN)
28    else:
29        typer.echo("Operation canceled")
30
31def _version_callback(value: bool) -> None:
32    # ...

Here’s how this code works:

  • Lines 8 and 9 define remove_all() as a Typer command using the @app.command() decorator with clear as the command name.

  • Lines 10 to 14 define force as a Typer Option. It’s a required option of the Boolean type. The prompt argument asks the user to enter a proper value to force, which can be either y or n.

  • Line 13 provides a help message for the force option.

  • Line 17 gets the usual Todoer instance.

  • Line 18 checks if force is True. If so, then the if code block removes all the to-dos from the database using .remove_all(). If something goes wrong during this process, the application prints an error message and exits (lines 21 to 25). Otherwise, it prints a success message on line 27.

  • Line 29 runs if the user cancels the remove operation by supplying a false value, indicating no, to force.

To give this new clear command a try, go ahead and run the following on your terminal:

(venv) $ python -m rptodo clear
Delete all to-dos? [y/N]:
Operation canceled

(venv) $ python -m rptodo clear
Delete all to-dos? [y/N]: y
All to-dos were removed

(venv) $ python -m rptodo list
There are no tasks in the to-do list yet

In the first example, you run clear. Once you press Enter, you get a prompt asking for yes (y) or no (N) confirmation. The uppercase N means that no is the default answer, so if you press Enter, you effectively cancel the clear operation.

In the second example, you run clear again. This time, you explicitly enter y as the answer to the prompt. This answer makes the application remove the entire to-do list from the database. When you run the list command, you get a message communicating that there are no tasks in the current to-do list.

That’s it! Now you have a functional CLI to-do application built with Python and Typer. Your application provides commands and options to create new to-dos, list all your to-dos, manage the to-do completion, and remove to-dos as needed. Isn’t that cool?

Conclusion

Building user-friendly command-line interface (CLI) applications is a fundamental skill to have as a Python developer. In the Python ecosystem, you’ll find several tools for creating this kind of application. Libraries such as argparse, Click, and Typer are good examples of those tools in Python. Here, you built a CLI application to manage a list of to-dos using Python and Typer.

In this tutorial, you learned how to:

  • Build a to-do application with Python and Typer
  • Add commands, arguments, and options to your to-do application using Typer
  • Test your to-do application using Typer’s CliRunner and pytest in Python

You also practiced some additional skills, such as working with JSON files using Python’s json module and managing configuration files with Python’s configparser module. Now you’re ready to build command-line applications.

You can download the entire code and all the resources for this project by clicking the link below and going to the source_code_final/ directory:

Next Steps

In this tutorial, you’ve built a functional to-do application for your command line using Python and Typer. Even though the application provides only a minimal set of features, it’s a good starting point for you to continue adding features and keep learning in the process. This will help you take your Python skills to the next level.

Here are a few ideas you can implement to continue extending your to-do application:

  • Add support for dates and deadlines: You can use the datetime module to get this done. This feature will allow the users to have better control over their tasks.

  • Write more unit tests: You can use pytest to write more tests for your code. This will increase the code coverage and help you improve your testing skills. You may discover some bugs in the process. If so, go ahead and post them in the comments.

  • Pack the application and publish it to PyPI: You can use Poetry or another similar tool to package your to-do application and publish it to PyPI.

These are just a few ideas. Take the challenge and build something cool on top of this project! You’ll learn a lot in the process.

import os

import argparse

INVALID_FILETYPE_MSG = "Error: Invalid file format. %s must be a .txt file."

INVALID_PATH_MSG = "Error: Invalid file path/name. Path %s does not exist."

def validate_file(file_name):

    if not valid_path(file_name):

        print(INVALID_PATH_MSG%(file_name))

        quit()

    else if not valid_filetype(file_name):

        print(INVALID_FILETYPE_MSG%(file_name))

        quit()

    return

def valid_filetype(file_name):

    return file_name.endswith('.txt')

def valid_path(path):

    return os.path.exists(path)

def read(args):

    file_name = args.read[0]

    validate_file(file_name)

    with open(file_name, 'r') as f:

        print(f.read())

def show(args):

    dir_path = args.show[0]

    if not valid_path(dir_path):

        print("Error: No such directory found.")

        exit()

    files = [f for f in os.listdir(dir_path) if valid_filetype(f)]

    print("{} text files found.".format(len(files)))

    print('n'.join(f for f in files))

def delete(args):

    file_name = args.delete[0]

    validate_file(file_name)

    os.remove(file_name)

    print("Successfully deleted {}.".format(file_name))

def copy(args):

    file1 = args.copy[0]

    file2 = args.copy[1]

    validate_file(file1)

    if not valid_filetype(file2):

        print(INVALID_FILETYPE_MSG%(file2))

        exit()

    with open(file1, 'r') as f1:

        with open(file2, 'w') as f2:

            f2.write(f1.read())

    print("Successfully copied {} to {}.".format(file1, file2))

def rename(args):

    old_filename = args.rename[0]

    new_filename = args.rename[1]

    validate_file(old_filename)

    if not valid_filetype(new_filename):

        print(INVALID_FILETYPE_MSG%(new_filename))

        exit()

    os.rename(old_filename, new_filename)

    print("Successfully renamed {} to {}.".format(old_filename, new_filename))

def main():

    parser = argparse.ArgumentParser(description = "A text file manager!")

    parser.add_argument("-r", "--read", type = str, nargs = 1,

                        metavar = "file_name", default = None,

                        help = "Opens and reads the specified text file.")

    parser.add_argument("-s", "--show", type = str, nargs = 1,

                        metavar = "path", default = None,

                        help = "Shows all the text files on specified directory path.

                        Type '.' for current directory.")

    parser.add_argument("-d", "--delete", type = str, nargs = 1,

                        metavar = "file_name", default = None,

                        help = "Deletes the specified text file.")

    parser.add_argument("-c", "--copy", type = str, nargs = 2,

                        metavar = ('file1','file2'), help = "Copy file1 contents to

                        file2 Warning: file2 will get overwritten.")

    parser.add_argument("--rename", type = str, nargs = 2,

                        metavar = ('old_name','new_name'),

                        help = "Renames the specified file to a new name.")

    args = parser.parse_args()

    if args.read != None:

        read(args)

    elif args.show != None:

        show(args)

    elif args.delete !=None:

        delete(args)

    elif args.copy != None:

        copy(args)

    elif args.rename != None:

        rename(args)

if __name__ == "__main__":

    main()

import os

import argparse

INVALID_FILETYPE_MSG = "Error: Invalid file format. %s must be a .txt file."

INVALID_PATH_MSG = "Error: Invalid file path/name. Path %s does not exist."

def validate_file(file_name):

    if not valid_path(file_name):

        print(INVALID_PATH_MSG%(file_name))

        quit()

    else if not valid_filetype(file_name):

        print(INVALID_FILETYPE_MSG%(file_name))

        quit()

    return

def valid_filetype(file_name):

    return file_name.endswith('.txt')

def valid_path(path):

    return os.path.exists(path)

def read(args):

    file_name = args.read[0]

    validate_file(file_name)

    with open(file_name, 'r') as f:

        print(f.read())

def show(args):

    dir_path = args.show[0]

    if not valid_path(dir_path):

        print("Error: No such directory found.")

        exit()

    files = [f for f in os.listdir(dir_path) if valid_filetype(f)]

    print("{} text files found.".format(len(files)))

    print('n'.join(f for f in files))

def delete(args):

    file_name = args.delete[0]

    validate_file(file_name)

    os.remove(file_name)

    print("Successfully deleted {}.".format(file_name))

def copy(args):

    file1 = args.copy[0]

    file2 = args.copy[1]

    validate_file(file1)

    if not valid_filetype(file2):

        print(INVALID_FILETYPE_MSG%(file2))

        exit()

    with open(file1, 'r') as f1:

        with open(file2, 'w') as f2:

            f2.write(f1.read())

    print("Successfully copied {} to {}.".format(file1, file2))

def rename(args):

    old_filename = args.rename[0]

    new_filename = args.rename[1]

    validate_file(old_filename)

    if not valid_filetype(new_filename):

        print(INVALID_FILETYPE_MSG%(new_filename))

        exit()

    os.rename(old_filename, new_filename)

    print("Successfully renamed {} to {}.".format(old_filename, new_filename))

def main():

    parser = argparse.ArgumentParser(description = "A text file manager!")

    parser.add_argument("-r", "--read", type = str, nargs = 1,

                        metavar = "file_name", default = None,

                        help = "Opens and reads the specified text file.")

    parser.add_argument("-s", "--show", type = str, nargs = 1,

                        metavar = "path", default = None,

                        help = "Shows all the text files on specified directory path.

                        Type '.' for current directory.")

    parser.add_argument("-d", "--delete", type = str, nargs = 1,

                        metavar = "file_name", default = None,

                        help = "Deletes the specified text file.")

    parser.add_argument("-c", "--copy", type = str, nargs = 2,

                        metavar = ('file1','file2'), help = "Copy file1 contents to

                        file2 Warning: file2 will get overwritten.")

    parser.add_argument("--rename", type = str, nargs = 2,

                        metavar = ('old_name','new_name'),

                        help = "Renames the specified file to a new name.")

    args = parser.parse_args()

    if args.read != None:

        read(args)

    elif args.show != None:

        show(args)

    elif args.delete !=None:

        delete(args)

    elif args.copy != None:

        copy(args)

    elif args.rename != None:

        rename(args)

if __name__ == "__main__":

    main()

Понравилась статья? Поделить с друзьями:
  • Как написать конец эссе
  • Как написать конец сочинения рассуждения
  • Как написать конец репортажа
  • Как написать конец презентации
  • Как написать конец объяснительной