この投稿からは、Fletを使ったアプリケーションの具体的な開発について説明していきます。アプリケーションの基本的な設計方法については、以前の投稿を参照してください。

以降で使用するPythonのバージョンは3.12です。

Python 3.12で取り入れられた機能(型ヒント)を使用しているため、以前のPythonでは構文エラーになります。注意してください。

今回作成するアプリケーションは、家計簿です。取引日、金額、分類、購入店を記録できるようにし、これまでに入力した取引の一覧も見られるようにします。入力した情報はデータベースに格納します。また、日常的に利用する店の名前は、文字入力の手間を省くため、短縮形で入力できるようにします(例えば「くすりの福太郎」であれば「kf」で入力できるようにする)。

アプリケーションのディレクトリ構成は、次のようになります。zipappパッケージでのアーカイブも考えているため、エントリポイントのファイル名は__main__.pyにしています。

__main__.py
kakei/
    agents/
        __init__.py
        ...
    flet_views/
        __init__.py
        ...
    libs/
        __init__.py
        ...
    schemas/
        __init__.py
        ...
    storages/
        __init__.py
        ...
    views/
        __init__.py
        ...

schemas – アプリケーションで使う情報の型定義

今回のアプリケーションで使用する情報はほとんどありません。最初はクラスメンバーの宣言だけでしたが、ユーティリティ的なメソッドが追加されていきました。ただ、使っているかどうか、よく憶えていません。

from typing import Self
from dataclasses import dataclass
from datetime import date


@dataclass
class Abbreviation:
    id: int
    abbr: str
    data: str

    def valid(self) -> bool:
        return not (bool(self.abbr) and bool(self.data))


@dataclass
class JournalEntry:
    id: int
    date: date
    price: int
    category: str
    shop: str

    def init_by(self, other: Self) -> None:
        self.id = other.id
        self.date = other.date
        self.price = other.price
        self.category = other.category
        self.shop = other.shop

storages – データストレージへのアクセス

今回は、使う情報の種類ごとにクラスを作成してみました。テスト用に機能制限版のstorageを作るということを考えると、コーディングの量を減らすためにも、storageは細かく分割しておいた方が良いと思われます。今回のような規模であれば、ひとつにまとめてしまっても問題ないかもしれません。

from abc import ABC, abstractmethod

import kakei.schemas as schemas


class Storage(ABC):
    def __init__(self) -> None:
        pass


class AbbreviationStorage(Storage):
    @abstractmethod
    def all_abbreviations(self) -> list[schemas.Abbreviation]:
        pass

    @abstractmethod
    def write_abbreviation(self, abbr: schemas.Abbreviation) -> None:
        pass


class JournalEntryStorage(Storage):
    @abstractmethod
    def write_journal_entry(self, jent: schemas.JournalEntry) -> None:
        pass

    @abstractmethod
    def select_journal_entries(
        self,
        offset: int = 0,
        limit: int = 0,
        order_by: str = "",
        desc: bool = False,
    ) -> list[schemas.JournalEntry]:
        """ストレージからJournalEntryのリストを取得する。
        offsetとlimitに0より大きい値が指定されたとき、offset番目から最大limit個のデータを取得する。
        """
        pass

    @abstractmethod
    def get_journal_entry(self, id: int) -> schemas.JournalEntry | None:
        pass

    @abstractmethod
    def count_journal_entries(self) -> int:
        pass

Pythonは型にこだわらない言語なので、そもそも抽象クラスという概念がありません。ライブラリで強引に実現しているので、個人的には、あまり好きな書き方ではありません。しかし、このように準備しておくと、メソッドの実装を忘れていたときにエラーを発生させてくれるので、デバッグ作業が楽になります。書かざるを得ません。

views – GUIのロジック部分

GUIのロジック部分は、基底クラスからGUIの各パーツが派生していくという構造にしています。

from collections.abc import Callable, Iterable

class ViewListeners[**P,R]:
    def __init__(self, *initials: Iterable[Callable[P, R] | None]) -> None:
        self.__listeners: list[Callable[P, R]] = []
        for e in initials:
            if self.__is_listener_appendable(e):
                self.__listeners.append(e)

    def __is_listener_appendable(self, listener: Callable[P, R] | None) -> bool:
        if listener is None:
            return False
        
        if listener in self.__listeners:
            return False
        
        return True

    def add_tail(self, listener: Callable[P, R] | None) -> bool:
        if self.__is_listener_appendable(listener):
            self.__listeners.append(listener)
            return True
        else:
            return False

    def add_head(self, listener: Callable[P, R] | None) -> bool:
        if self.__is_listener_appendable(listener):
            self.__listeners.insert(0, listener)
            return True
        else:
            return False

    def fire(self, *args, **kwargs) -> list[R]:
        return [listener(*args, **kwargs) for listener in self.__listeners]

    def remove(self, listener: Callable[P, R]) -> bool:
        buf = []
        for e in self.__listeners:
            if e != listener:
                buf.append(e)

        if len(buf) != len(self.__listeners):
            self.__listeners = e
            return True
        else:
            return False


class View:
    pass

コールバックを使うと分かりやすいコードになるため、ViewListenersクラスを作成しています。コールバックを登録していき、fireメソッドで一気に呼び出すという簡単なものですが、重要な役割を果たしてくれるクラスです。

from typing import Callable
from collections.abc import Iterable, Collection
import datetime

import kakei.schemas as schemas
import kakei.agents as agents
import kakei.libs as libs
from .view import View, ViewListeners


class DateField(View):
    def __init__(
        self,
        date: datetime.date | None = None,
        on_changed: Callable[[], None] | None = None,
    ) -> None:
        if date is None:
            self.__year = self.__month = self.__day = ""
        else:
            self.__year = str(date.year)
            self.__month = str(date.month)
            self.__day = str(date.day)
        self.on_changed = ViewListeners[[], None](on_changed)

    @property
    def year(self) -> str:
        return self.__year

    @property
    def month(self) -> str:
        return self.__month

    @property
    def day(self) -> str:
        return self.__day

    @property
    def year_int(self) -> int:
        try:
            value = int(self.__year)
            if 2000 <= value <= 2100:
                return value
            else:
                return 0
        except:
            return 0

    @property
    def month_int(self) -> int:
        try:
            value = int(self.__month)
            if 1 <= value <= 12:
                return value
            else:
                return 0
        except:
            return 0

    @property
    def day_int(self) -> int:
        try:
            value = int(self.__day)
            if 1 <= value <= 31:
                return value
            else:
                return 0
        except:
            return 0

    @property
    def date(self) -> datetime.date | None:
        try:
            return datetime.date(self.year_int, self.month_int, self.day_int)
        except:
            return None

    def set_year_month_day(self, year: str, month: str, day: str) -> None:
        if self.__year == year and self.__month == month and self.__day == day:
            return
        self.__year = year
        self.__month = month
        self.__day = day
        self.on_changed.fire()

    def set_date(self, date: datetime.date | None) -> None:
        if date is None:
            self.set_year_month_day("", "", "")
        else:
            self.set_year_month_day(str(date.year), str(date.month), str(date.day))

    def set_year(self, year: str) -> None:
        self.set_year_month_day(year, self.__month, self.__day)

    def set_month(self, month: str) -> None:
        self.set_year_month_day(self.__year, month, self.__day)

    def set_day(self, day: str) -> None:
        self.set_year_month_day(self.__year, self.__month, day)

    @property
    def year_error(self) -> str | None:
        value = self.__year
        if len(value) == 0:
            return "入力必須"
        else:
            try:
                year = int(value)
                if not (2000 <= year <= 2100):
                    return "不正な値"
            except:
                return "不正な文字列"
        return None

    @property
    def month_error(self) -> str | None:
        value = self.__month
        if len(value) == 0:
            return "入力必須"
        else:
            try:
                month = int(value)
                if not (1 <= month <= 12):
                    return "不正な値"
            except:
                return "不正な文字列"
        return None

    @property
    def day_error(self) -> str | None:
        value = self.__day
        if len(value) == 0:
            return "入力必須"
        else:
            try:
                day = int(value)
                if not (1 <= day <= 31):
                    return "不正な値"
                if (year := self.year_int) > 0 and (month := self.month_int) > 0:
                    try:
                        datetime.date(year, month, day)
                    except:
                        return "不正な値"
            except:
                return "不正な文字列"
        return None


class PriceField(View):
    def __init__(
        self,
        price: int | None = None,
        on_changed: Callable[[], None] | None = None,
    ) -> None:
        if price is None:
            self.__price = ""
        else:
            self.__price = str(price)
        self.on_changed = ViewListeners[[], None](on_changed)

    @property
    def price(self) -> str:
        return self.__price

    @property
    def price_int(self) -> int | None:
        try:
            value = int(self.__price)
            if 0 <= value:
                return value
            else:
                return None
        except:
            return None

    def set_price(self, price: str) -> None:
        if self.__price == price:
            return
        self.__price = price
        self.on_changed.fire()

    @property
    def price_error(self) -> str | None:
        value = self.__price
        if len(value) == 0:
            return "入力必須"
        else:
            try:
                price = int(value)
                if price < 0:
                    return "不正な値"
            except:
                return "不正な文字列"
        return None


class WithAbbrField(View):
    def __init__(
        self,
        abbr: str = "",
        text: str = "",
        agent: agents.AbbreviationAgent | None = None,
        on_changed: Callable[[], None] | None = None,
        on_expanded: Callable[[agents.AbbreviationAgent.ExpandResult], None]
        | None = None,
    ) -> None:
        self.__abbr = abbr
        self.__text = text
        self.__agent = agent
        self.on_changed = ViewListeners[[], None](on_changed)
        self.on_expanded = ViewListeners[[agents.AbbreviationAgent.ExpandResult], None](
            on_expanded
        )

    @property
    def abbr(self) -> str:
        return self.__abbr

    @property
    def text(self) -> str:
        return self.__text

    @property
    def pair(self) -> tuple[str, str]:
        return (self.__abbr, self.__text)

    def set_abbr(self, abbr: str) -> None:
        if self.__abbr == abbr:
            return
        self.__abbr = abbr
        text = None
        if self.__agent is not None:
            r = self.__agent.expand(self.__abbr)
            self.on_expanded.fire(r)
            if r.eq is not None:
                text = r.eq.data
            else:
                a: list[schemas.Abbreviation] = []
                for e in r.heads:
                    if e.abbr.startswith(abbr):
                        a.append(e)
                if len(a) == 1:
                    text = a[0].data
        if text is not None and self.__text != text:
            self.__text = text
        self.on_changed.fire()

    def set_text(self, text: str) -> None:
        if self.__text == text:
            return
        self.__text = text
        self.on_changed.fire()

    def set_abbr_text(self, abbr: str, text: str) -> None:
        if self.__abbr == abbr and self.__text == text:
            return
        self.__abbr = abbr
        self.__text = text
        self.on_changed.fire()


class CategoryField(WithAbbrField):
    def __init__(
        self,
        abbr: str = "",
        category: str = "",
        agent: agents.AbbreviationAgent | None = None,
        on_changed: Callable[[], None] | None = None,
        on_expanded: Callable[[agents.AbbreviationAgent.ExpandResult], None]
        | None = None,
    ) -> None:
        super().__init__(abbr, category, agent, on_changed, on_expanded)

    @property
    def category(self) -> str:
        return self.text

    def set_category(self, category: str) -> None:
        self.set_text(category)

    def set_abbr_category(self, abbr: str, category: str) -> None:
        self.set_abbr_text(abbr, category)


class ShopField(WithAbbrField):
    def __init__(
        self,
        abbr: str = "",
        shop: str = "",
        agent: agents.AbbreviationAgent | None = None,
        on_changed: Callable[[], None] | None = None,
        on_expanded: Callable[[agents.AbbreviationAgent.ExpandResult], None]
        | None = None,
    ) -> None:
        super().__init__(abbr, shop, agent, on_changed, on_expanded)

    @property
    def shop(self) -> str:
        return self.text

    def set_shop(self, shop: str) -> None:
        self.set_text(shop)

    def set_abbr_shop(self, abbr: str, shop: str) -> None:
        self.set_abbr_text(abbr, shop)


class AbbreviationTable(View):
    def __init__(
        self,
        abbrs: Iterable[schemas.Abbreviation] = [],
        label: str = "",
        on_changed: Callable[[], None] | None = None,
    ):
        self.__abbrs = tuple(abbrs)
        self.__label = label
        self.on_changed = ViewListeners[[], None](on_changed)

    def set_abbrs(self, abbrs: Iterable[schemas.Abbreviation], label: str = "") -> None:
        if libs.is_same_contents(self.__abbrs, abbrs):
            if self.__label == label:
                return
            else:
                self.__label = label
        else:
            self.__abbrs = tuple(abbrs)
            self.__label = label

        self.on_changed.fire()

    @property
    def abbrs(self) -> tuple[schemas.Abbreviation]:
        return self.__abbrs

    @property
    def label(self) -> str:
        return self.__label


class JournalEntryRegister(View):
    def __init__(
        self,
        agent: agents.JournalEntryRegisterAgent | None = None,
        on_registered: Callable[[], None] | None = None,
        on_error: Callable[[Exception], None] | None = None,
    ):
        self.agent = agent
        self.date_field = DateField()
        self.price_field = PriceField()
        self.category_field = CategoryField(
            agent=agent.category, on_expanded=self.on_category_expanded
        )
        self.shop_field = ShopField(agent=agent.shop, on_expanded=self.on_shop_expanded)
        self.abbr_table = AbbreviationTable()
        self.on_registered = ViewListeners[[], None](on_registered)
        self.on_error = ViewListeners[[Exception], None](on_error)

    def register(self):
        try:
            date = self.date_field.date
            if date is None:
                raise RuntimeError("invalid date value.")

            price = self.price_field.price_int
            if price is None:
                raise RuntimeError("invalid price value.")

            category = self.category_field.category
            shop = self.shop_field.shop

            if self.agent is not None:
                self.agent.register(
                    schemas.JournalEntry(0, date, price, category, shop)
                )
                self.agent.category.update(*self.category_field.pair)
                self.agent.shop.update(*self.shop_field.pair)
                self.on_registered.fire()

            self.date_field.set_year_month_day("", "", "")
            self.price_field.set_price("")
            self.category_field.set_abbr_category("", "")
            self.shop_field.set_abbr_shop("", "")
            self.abbr_table.set_abbrs([])
        except Exception as e:
            self.on_error.fire(e)

    def on_category_expanded(self, result: agents.AbbreviationAgent.ExpandResult):
        self.abbr_table.set_abbrs(result.heads, "分類")

    def on_shop_expanded(self, result: agents.AbbreviationAgent.ExpandResult):
        self.abbr_table.set_abbrs(result.heads, "店名")

取引の登録を行うGUIアプリケーションの論理部分を作成すると、このようになります。各クラスはGUIアプリケーションを構成するパーツに対応します。登録部分においてはJournalEntryRegisterがパーツをまとめ上げる役割を果たしており、先に定義したクラスを構成要素に持ちます。

まだ紹介していないagentsのクラスを使っていますが、何をやっているかは、ある程度、想像が付くのではないかと思います。

このように、GUIアプリケーションの骨格部分だけを作っておくと、次のようなテストができるようになります。

import pytest
import datetime

import kakei.schemas as schemas
import kakei.agents as agents
import kakei.storages.memory as storages

from .register import (
    DateField,
    PriceField,
    CategoryField,
    AbbreviationTable,
    JournalEntryRegister,
)


def test_DateField():
    changed = False

    def on_changed():
        nonlocal changed
        changed = True

    view = DateField(date=None, on_changed=on_changed)
    assert view.year == ""
    assert view.year_int == 0
    assert view.year_error != None
    assert view.month == ""
    assert view.month_int == 0
    assert view.month_error != None
    assert view.day == ""
    assert view.day_int == 0
    assert view.day_error != None
    assert view.date == None

    changed = False
    view.set_year("1")
    assert changed == True
    assert view.year == "1"
    assert view.year_int == 0
    assert view.year_error != None
    assert view.month == ""
    assert view.month_int == 0
    assert view.month_error != None
    assert view.day == ""
    assert view.day_int == 0
    assert view.day_error != None
    assert view.date == None

    changed = False
    view.set_month("13")
    assert changed == True
    assert view.year == "1"
    assert view.year_int == 0
    assert view.year_error != None
    assert view.month == "13"
    assert view.month_int == 0
    assert view.month_error != None
    assert view.day == ""
    assert view.day_int == 0
    assert view.day_error != None
    assert view.date == None

    changed = False
    view.set_day("x")
    assert changed == True
    assert view.year == "1"
    assert view.year_int == 0
    assert view.year_error != None
    assert view.month == "13"
    assert view.month_int == 0
    assert view.month_error != None
    assert view.day == "x"
    assert view.day_int == 0
    assert view.day_error != None
    assert view.date == None

    changed = False
    view.set_year_month_day(year="2023", month="5", day="12")
    assert changed == True
    assert view.year == "2023"
    assert view.year_int == 2023
    assert view.year_error == None
    assert view.month == "5"
    assert view.month_int == 5
    assert view.month_error == None
    assert view.day == "12"
    assert view.day_int == 12
    assert view.day_error == None
    assert view.date == datetime.date(2023, 5, 12)

    changed = False
    view.set_year("2023")
    assert changed == False

    changed = False
    view.set_month("5")
    assert changed == False

    changed = False
    view.set_day("12")
    assert changed == False


def test_PriceField():
    changed = False

    def on_changed():
        nonlocal changed
        changed = True

    view = PriceField(price=None, on_changed=on_changed)
    assert view.price == ""
    assert view.price_int == None
    assert view.price_error != None

    changed = False
    view.set_price("-10")
    assert view.price == "-10"
    assert view.price_int == None
    assert view.price_error != None

    changed = False
    view.set_price("100")
    assert view.price == "100"
    assert view.price_int == 100
    assert view.price_error == None


def test_CategoryField():
    changed = False

    def on_changed():
        nonlocal changed
        changed = True

    abbrs = [
        schemas.Abbreviation(1, "aaa", "AAA"),
        schemas.Abbreviation(2, "ab", "AB"),
        schemas.Abbreviation(3, "abc", "ABC"),
    ]

    view = CategoryField(
        agent=agents.AbbreviationAgent(storages.AbbreviationStorage(abbrs)),
        on_changed=on_changed,
    )
    assert view.abbr == ""
    assert view.category == ""

    changed = False
    view.set_category("X")
    assert view.abbr == ""
    assert view.category == "X"
    assert changed == True

    changed = False
    view.set_abbr("a")
    assert view.abbr == "a"
    assert view.category == "X"
    assert changed == True

    changed = False
    view.set_abbr("aa")
    assert view.abbr == "aa"
    assert view.category == "AAA"
    assert changed == True

    changed = False
    view.set_abbr("ab")
    assert view.abbr == "ab"
    assert view.category == "AB"
    assert changed == True

    changed = False
    view.set_abbr("a")
    assert view.abbr == "a"
    assert view.category == "AB"
    assert changed == True

    changed = False
    view.set_abbr("a")
    assert view.abbr == "a"
    assert view.category == "AB"
    assert changed == False

    changed = False
    view.set_category("AB")
    assert view.abbr == "a"
    assert view.category == "AB"
    assert changed == False


def test_AbbreviationTable():
    changed = False

    def on_changed():
        nonlocal changed
        changed = True

    view = AbbreviationTable(on_changed=on_changed)

    changed = False
    view.set_abbrs(
        [
            schemas.Abbreviation(1, "a", "A"),
            schemas.Abbreviation(2, "b", "B"),
        ]
    )
    assert view.abbrs == (
        schemas.Abbreviation(1, "a", "A"),
        schemas.Abbreviation(2, "b", "B"),
    )
    assert changed == True

    changed = False
    view.set_abbrs(
        [
            schemas.Abbreviation(1, "a", "A"),
            schemas.Abbreviation(2, "b", "B"),
        ]
    )
    assert view.abbrs == (
        schemas.Abbreviation(1, "a", "A"),
        schemas.Abbreviation(2, "b", "B"),
    )
    assert changed == False


def test_JournalEntryRegister():
    error = None
    def on_error(e: Exception):
        nonlocal error
        error = e

    journal_entries = []
    abbrs = [
            schemas.Abbreviation(1, "a", "A"),
            schemas.Abbreviation(2, "b", "B"),
    ]
    journal_entry_storage = storages.JournalEntryStorage(journal_entries)
    view = JournalEntryRegister(
        agents.JournalEntryRegisterAgent(
            storage=journal_entry_storage,
            category=agents.AbbreviationAgent(storages.AbbreviationStorage(abbrs)),
            shop=agents.AbbreviationAgent(storages.AbbreviationStorage(abbrs)),
        ),
        on_error=on_error,
    )

    error = None
    view.register()
    assert error != None
    assert len(journal_entry_storage.buffer) == 0

    error = None
    view.date_field.set_year_month_day("2023", "10", "5")
    view.price_field.set_price("20000")
    view.category_field.set_category("C")
    view.shop_field.set_abbr("a")
    assert view.abbr_table.abbrs == (
        schemas.Abbreviation(1, "a", "A"),
    )
    view.register()
    assert error == None
    assert len(journal_entry_storage.buffer) == 1
    assert journal_entry_storage.buffer[0] == schemas.JournalEntry(1, datetime.date(2023, 10, 5), 20000, "C", "A")