簡単なCLIツール ToDoリスト

【個人ブログ】開発過程と学習記録

投稿日

2週間前にCLIツールというのを初めて作ってみました!
最初はargparseを使いましたが、細かい設定ができる反面、コードが長くなりやすく感じました。
そこで、よりシンプルなコードで書けるTyperを学び、簡単なtodoリストを作りました。
リストの追加と特定のリストの削除、そしてリストを表示させることができるようにしました。

使用ライブラリ

typer.Argumentは位置引数として使い、必須引数にしたいときに使えますがデフォルト設定をすれば、オプションのように使えます。typer.Optionと違って、引数を渡す際に–detailのようにオプション名を書く必要がないのも楽です。

from typing_extensions import Annotated
import typer
from rich.console import Console
from rich.table import Table
import chardet
import re

タスク追加関数

@app.command()
def add_task(
        task: Annotated[str, typer.Argument(help="追加したいタスクを入力")],
        detail: Annotated[str, typer.Argument(help="タスクの詳細を入力")] = "",
        file_path: Annotated[str, typer.Argument(help="セーブするファイルパスを入力")] = None,
):
    check = f"{task} : {detail}"

# ファイル変換の処理はなくてもok
    try:
        with open(file_path, "rb") as f:
            raw_data = f.read()

        detected_encoding = chardet.detect(raw_data)["encoding"]

        if detected_encoding and detected_encoding.lower() not in ["utf-8", "utf-8-sig"]:
            text = raw_data.decode(detected_encoding)
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(text)
            typer.echo("🔄 既存のファイルを UTF-8 に変換しました。")

        with open(file_path, "r", encoding="utf-8") as f:
            for line in f.readlines():
                if check in line:
                    print("同じタスクがすでに登録されています")
                    return

        with open(file_path, "a", encoding="utf-8") as f:
            f.write(f"{check}\n")
            typer.echo("✅タスクをファイルに保存しました")

    except Exception as e:
        typer.echo(f"エラー: {e}", err=True)


ファイルエラーなど自動で出力してくれるtyper.FileTexttyper.FileTextWriteという便利なのがあるのですが、ここでは読み書き両方行ってるので使うのに適していません。
追加する際にテキストの最後に改行を入れることで、追加するたびに段落を変えて追加できます。err=Trueにすると標準エラー出力(stderr)に出力されます。エラー系の出力と標準のエラーの出力を分ける時に使うらしいです。
ファイル変換でcherdetを使ってますが、自分しか使わないのであればければ基本的には必要ないです。あっても支障はないので一応書いときました。他にも、文字型にPathが使えてwith openを使う必要がなくなりますが、ここでは使っていません。

タスク削除関数

@app.command()
def delete_task(
    task: Annotated[str, typer.Argument(help="削除したいタスクを入力")],
    detail: Annotated[str, typer.Option(help="削除したいタスクの内容を入力。未入力の場合は該当するタスクを全て消します。")] = "",
    file_path: Annotated[str, typer.Argument(help="タスクを削除するファイルパスを入力")] = None,
):
    try:
        with open(file_path, "rb") as f:
            raw_data = f.read()

        detected_encoding = chardet.detect(raw_data)["encoding"]

        if detected_encoding and detected_encoding.lower() not in ["utf-8", "utf-8-sig"]:
            text = raw_data.decode(detected_encoding)
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(text)
            typer.echo("🔄 既存のファイルを UTF-8 に変換しました。")

        with open(file_path, "r", encoding="utf-8") as f:
            text = f.readlines()

        if any(task in line for line in text):
            if detail == "":
                new_lines = [line for line in text if not re.search(task, line)]
            else:
                target = fr"{task}\s+:\s+{detail}"
                new_lines = [line for line in text if not re.search(target, line)]

            with open(file_path, "w", encoding="utf-8") as f:
                f.writelines(new_lines)
                print(f"✅{task}を削除しました")

        else:
            print("入力されたタスクは存在しませんでした")

    except Exception as e:
        typer.echo(f"エラー: {e}", err=True)

この関数を書くのには時間がかかりました…。特定のタスクを空文字に書き換えるとすべて消えてしまいます。なので、消さないリストを変数に入れといて、置き換えた後に追加するという感じです。ファイル操作をあまりやる機会がなく知識があまりなかったのですが、学ぶきっかけになって良かったです!

同じタスク名があった場合はタスク内容の有無で処理が変わります。タスク名のみ送られた場合は、該当するすべてのタスクを消します。内容も一緒に送られた場合は、どちらも一致するタスクだけを消すようにしています。

タスク表示関数

@app.command()
def display_todo_list(
    file_path: Annotated[typer.FileText, typer.Argument(help="表示するファイルパスを入力", encoding="utf-8")]
):
    console = Console()
    table = Table("タスク名", "内容")

    text = file_path.readlines()

    tasks = [line.split(":")[0].strip() for line in text]
    details = [line.split(":")[1].strip() for line in text]

    for task, detail in zip(tasks, details):
        table.add_row(task, detail)
    console.print(table)

rich.tableを使い表形式で表示するようにしました。
ここではtyper.FileTextを使っているのでtry-exceptはあえて使っていません。これを使うとPathと同様にwith openを使わず.readlines()だけで直接読み込むことができます。ただし、先ほ書いた通り、読み書きの両方を行う場合は適していません。

これを出力すると、

このような感じになります!
richはきれいなきれいな出力が可能で、色付ける場合もrichを使った方がきれいに出力できます。
typer.colorsと比較すると、

同じ色でも彩度が違うのが分かると思います。
richは画像の:sparkles:のような絵文字のショートコードにも対応しています。

もっと簡単に作るつもりでしたが意外と時間をかけて作ってしまいました…。
制作期間は2日かかりました。
かなりシンプルなToDoリストですが、初めて開発したツールとしては満足しています!

最後まで読んでいただきありがとうございました!

コメント

タイトルとURLをコピーしました