この投稿からは、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.shopstorages – データストレージへのアクセス
今回は、使う情報の種類ごとにクラスを作成してみました。テスト用に機能制限版の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:
passPythonは型にこだわらない言語なので、そもそも抽象クラスという概念がありません。ライブラリで強引に実現しているので、個人的には、あまり好きな書き方ではありません。しかし、このように準備しておくと、メソッドの実装を忘れていたときにエラーを発生させてくれるので、デバッグ作業が楽になります。書かざるを得ません。
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")