この投稿からは、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")