0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Fletを利用したXBRLファイラー その1 はじめてのFlet DeclarativeUI

0
Posted at

Fletが1.0Beta(0.80)にメジャーアップデートされて、declarative style(宣言型UI)と非同期処理が導入されました。
0.28までのupdate()でコントロールを変更していくimperative style(命令型)にも対応しているようですが、declarativeUIではコントロールに関連した変数の状態をstatusとして監視され、これがイベントで変更されるたびにfletコンポーネントが再描画される仕様です。

しかし、Fletウェブページのdocumentには新たなdeclarativeUIに関する説明チャプターがみつかりにくく、実のところ最近までは気が付かずに0.8を利用してました。
過去に0.28で作成していたアプリを0.8対応にアップデートしているうちに不具合がいくつか発生しました。

in _call_connection_lost
    self._sock.shutdown(socket.SHUT_RDWR)
    ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
ConnectionResetError: [WinError 10054] 既存の接続はリモート ホストに強制的に切断されました。

特にアプリ終了時に上記のようなエラーが出現してコマンドプロンプトに復帰できなくなることが度々あり、ウェブでいろいろと調べているうちにFlet本家のブログ1でdeclarativeUIに対応した1.0Betaとしてのメジャーアップデートであったことに初めて気が付いた次第です。
このブログに加えて、Fletのドキュメント2とGoogle検索で見つけた日本語の説明ページ34のおかげでおおまかな概念が理解できました。
加えて、GitHubのサンプル5コードを見ながら試行錯誤を繰り返すことで、過去に命令型で作成したXBRLファイラーアプリをdeclarativeUI仕様に変更することができました。
xbrl_filer.png
自己流の解釈で、根本的に間違っている部分もあるかもしれませんが、大幅な変更で慣れるまで時間がかかったことから備忘録をかねてアップしておくことにしました。

コンポーネント

Fletの命令型でGUIウィンドウを表示するには、

# imperative

page.add(control)

または

# imperative

page.views.append(view)
page.update()

のようにmainメソッドに書いていました。

これに対して、declarativeにおいてはViewまたはコントロール(リスト)を@ft.componentでコンポーネント化して、page.render(APP)またはpage.render_views(APP)のように呼び出すことでウィンドウの表示ができます。

# declarative

import flet as ft

@ft.component
def App():
    return [ft.Text('test component')]

ft.run(lambda page: page.render(App))

または

# declarative

@ft.component
def App():
    return ft.View([ft.Text('test component View')])

ft.run(lambda page: page.render_views(App))

コントロールイベントによるstate変更と再描画

0.28までの命令型UIでは、コントロールのイベントでコントロールを追加ないし変更したのちにupdate()で再描画する必要がありました。

declarativeUIの場合はft.use_state()でstate変数またはインスタンスをコンポーネント内で作成し、コントロールイベントにおいてそれを変更することでコンポーネントが再実行され、stateの条件に応じてコントロール描画を変更することができる仕様のようです。

import flet as ft
import logging

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)

@ft.component
def App():
    def on_click(e):
        # button親Rowの先頭コントロール(TextField)
        textfield = e.control.parent.controls[0]
        # stateの値を設定
        set_state(textfield.value)
    
    logger.info('start App')
    state, set_state = ft.use_state("初期値")
    
    return ft.View([
        ft.Column(
            [
                ft.Row([
                    ft.TextField(label='input text'),
                    ft.Button(ft.Text('input'), on_click=on_click)
                ]),
                ft.Text(state)
            ]
        )
        
    ])
    
ft.run(lambda page: page.render_views(App))

上記コードはTextFieldに文字を入力してボタンを押すことでその下のTextコントロールの表示文字列が入力文字列に変更されるコードです。
state, set_state = ft.use_state("初期値")の行でstate変数に文字列「初期値」が格納されft.Text(state)により表示されます。
ボタンを押すことでon_clickイベント内のset_state(textfield.value)によりstatus変数が変更されますが、この時コンソールにloggingメッセージが再出力されることから、state変更によりコンポーネントの初めから再実行されていることがわかりました。

さらにstate変数をクラス化することによって、複数のフィールド変数の状態により再描画を分岐変更することが可能になります。
@ft.observableについては明確には理解していませんが、state変更を監視するためのおまじないのようで、class Status(ft.Observable):のように継承でも同じ効果で1、stateクラスインスタンスのフィールド値を変更するたびに再描画トリガーとなるようです。

import flet as ft
from dataclasses import dataclass
import logging

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)

@dataclass
@ft.observable
class Status:
    text: str = '初期値'
    color_name: str|None = None
    def update(self, text, color_name):
        self.text = text
        self.color_name = color_name

@ft.component
def App():
    def on_click(e):
        state.update(tf.value, radio.value)
        
    
    state, _ = ft.use_state(Status())
    logger.info(f'start App {state}')
    tf = ft.TextField(label='input text')
    
    match state.color_name:
        case 'red':
            txt_color = ft.Colors.RED
        case 'green':
            txt_color = ft.Colors.GREEN
        case 'blue':
            txt_color = ft.Colors.BLUE
        case _:
            txt_color = ft.Colors.WHITE
            
    radio = ft.RadioGroup(
            content=ft.Column(
                controls=[
                    ft.Radio(value="red", label="Red"),
                    ft.Radio(value="green", label="Green"),
                    ft.Radio(value="blue", label="Blue"),
                ]
            )
        )
    return ft.View([
        ft.Column(
            [
                ft.Row([
                    tf,
                    radio,
                    ft.Button(ft.Text('input'), on_click=on_click)
                ]),
                ft.Text(state.text, bgcolor=txt_color)
            ]
        )
        
    ])
    
ft.run(lambda page: page.render_views(App))

ディレクトリ内にあるzipファイルを一覧表示して書庫内のファイル一覧をリスト表示するファイラーアプリ

このコンポーネントの表示方法とstatus変更方法さえ理解できればdeclarativeUIのコーディングができると思います。

以下はツールバー、サイドバー、メインウインドウを有するdeclarativeUI仕様の簡易zipファイラーのサンプルです。
AppView内でAppStateクラスのインスタンスを作成しそれをstatusとしてSidebarとMainWindowコンポーネントに渡すことで、いずれのコンポーネント内のイベントでstatusが変更されても再描画が簡単に行えます。

  1. 最初の起動時にファイルダイアログを表示するボタンを含む設定レイアウトが表示されます
  2. ファイルダイアログで任意のzipファイルを選択することで、そのディレクトリが初期ディレクトリとしてjsonファイルを作成してサイドバーにzipファイル一覧をリスト表示します
  3. サイドバーのクリックしたファイルが選択アイテムとして色が変わり、メインレイアウトにDataTable2として書庫内のファイル一覧がリスト表示されます
  4. 書庫内のファイルをダブルクリックすることでファイルがブラウザ表示可能です(拡張子によってはOSで関連づけられたアプリが起動するようです)
  5. サイドバーのフォルダをダブルクリックすることでターゲットディレクトリの変更が可能です(jsonで出力した初期ディレクトリは変更されない)
  6. ツールバーにTDnet等のwebページをブラウザ表示するPopupMenuItemを追加しています
main.py
import flet as ft
import flet_datatable2 as ftd
from dataclasses import dataclass, field
import logging
import os
import zipfile
import json
import webbrowser

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
logger.warning('###   \033[35mstart zip filer\033[0m   ###')

SETTINGFILE = os.path.join(os.path.dirname(__file__), 'settings.json')

class ZipArchive:
    def __init__(self, zip_filepath: str):
        self.path = zip_filepath
        self.files: list[str] = []
        try:
            with zipfile.ZipFile(self.path, 'r') as z:
                for filename in z.namelist():
                    self.files.append(filename)
        except zipfile.BadZipFile as e:
            logger.error(f'{filename}:\033[31mファイル破損\033[0m')
        except zipfile.LargeZipFile as e:
            logger.error(f'{filename}:\033[31mZIP64\033[0m')
        except Exception as e:
            logger.error(f'{filename}:\033[31munknown error\033[0m')

    def get_source(self, target_filename, encoding=None):
        try:
            with zipfile.ZipFile(self.path, 'r') as z:
                if encoding is None:
                    source = z.read(target_filename)
                else:
                    # ZIP内の特定のファイル(メンバー)をファイルライクオブジェクトとして開きます
                    with z.open(target_filename, 'r') as fp:
                        # バイトデータを読み込み、指定したエンコーディングでデコードします
                        # read()はバイト文字列を返すため、decode()が必要です
                        source = fp.read().decode(encoding)
            return source
        except Exception as e:
            logger.error(f'\033[31mソース取得エラー\033[0m\n{e}')

@ft.observable
@dataclass
class AppState:    
    dir: str|None = None   # zip document を含むカレントディレクトリ
    current_doc: str|None = None     # 選択中のzip書類
    current_content: str|None = None # 書類書庫内で選択中のファイル
    layout: str|None = 'setting'     # MainWindowで表示するレイアウト
    layoutes: list[str] = field(default_factory=lambda: ["setting", "list"]) # メインレイアウト一覧
    documents: list[str] = field(default_factory=list)  # zipファイル一覧
    
    def initialize(self):
        if os.path.exists(SETTINGFILE):
            settings = json.load(open(SETTINGFILE, 'r'))
            self.change_directory(settings['dir'])
        else:            
            logger.warning('設定ファイルありません')
            
    def change_directory(self, dir):
        self.dir = dir
        self.documents = []
        for fname in os.listdir(dir):
            if fname.endswith('.zip'):
                doc_id = os.path.splitext(fname)[0]
                self.documents.append(doc_id)
        if len(self.documents) > 0:
            self.current_doc = self.documents[0]
            self.layout = 'list'
        if not os.path.exists(SETTINGFILE):
            with open(SETTINGFILE, 'w') as f:
                json.dump({'dir': dir}, f, indent=4)

    def document_select_changed(self, e):
        logger.info(f"document_select_changed {e.control.data}")
        self.current_doc = e.control.data
        self.current_content = None

    #フォルダのダブルクリック
    def on_double_tap(self, e):
        logger.info(f'on_double_tap: {e.control.data}')
        self.change_directory(e.control.data)

    def change_content(self, content):
        self.current_content = content
        
# fletのエントリポイント
@ft.component
def AppView():
    state, _ = ft.use_state(lambda:AppState())
    if state.dir is None:
        state.initialize()
    logger.info(f'----------- AppView ------------- loaded')
    toolbar = Toolbar()
    sidebar = Sidebar(state)
    main_window = MainWindow(state)
    return ft.View(
        appbar=toolbar,
        controls=[
            ft.Row(
                spacing=5,
                expand=True,
                controls=[
                    sidebar,
                    ft.VerticalDivider(width=5, thickness=3),
                    ft.Container(content=main_window, expand=True),
                ]                
            )
        ],        
    )

@ft.component
def Sidebar(state):
    def sidebar_on_click(e):
        state.document_select_changed(e)
    def sidebar_double_click(e):
        state.on_double_tap(e)

    logger.info(f'-- Sidebar -- {state.current_doc}')
    
    item_list = []
    if state.dir is not None:
        dict_dir = {}
        parent_dir = os.path.dirname(state.dir)
        if os.path.exists(parent_dir):
            dict_dir['..'] = parent_dir
        for entry in os.scandir(state.dir):
            if entry.is_dir():
                dict_dir[entry.name] = os.path.join(state.dir, entry.name)
        for key, path in dict_dir.items():
            item = ft.GestureDetector(
                        ft.ListTile(leading=ft.Icons.FOLDER, icon_color=ft.Colors.GREY_400 if key=='..' else ft.Colors.AMBER_200, height=32, title=key, width=140),
                        data=path, on_double_tap=sidebar_double_click
                    )
            item_list.append(item)
        
    for doc_id in state.documents:
        item = ft.Container(
                    ft.Text(doc_id, size=11), 
                    data=doc_id,
                    bgcolor=ft.Colors.CYAN_50 if state.current_doc == doc_id else None , 
                    on_click=sidebar_on_click,
                    tooltip=doc_id,
                )
        item_list.append(item)
            
    return ft.Column(
        [
            ft.Column(
                item_list,
                expand=True, scroll=ft.ScrollMode.AUTO,
            )
        ],
        width=128,
        expand_loose=True,
    )

@ft.component
def Toolbar():
    async def on_events(e):
        match e.control.data:
            case 'tdnet':
                webbrowser.open("https://www.release.tdnet.info/inbs/I_main_00.html")
            case 'edinet':
                webbrowser.open('https://disclosure2.edinet-fsa.go.jp/week0010.aspx')
            case 'ufo':
                webbrowser.open("https://ufocatch.com/")

    logger.info(f'-- Toolbar --')
    return ft.AppBar(
        title=ft.Text("XBRL zip ファイラー", size=18),
        actions=[
            ft.VerticalDivider(thickness = 3),
            ft.PopupMenuButton(
                items=[
                    ft.PopupMenuItem(content="Tdnet", data = "tdnet", on_click=on_events,),
                    ft.PopupMenuItem(content="EDINET", data = "edinet", on_click=on_events,),
                    ft.PopupMenuItem(content="有報キャッチャー", data = "ufo", on_click=on_events,),
                ]
            ),
        ],        
    )

# stateインスタンスのlayout変数でコンポーネント自体を変更
@ft.component
def MainWindow(state):
    logger.warning(f'-- MainWindow -- {state.layout}')
    match state.layout:
        case 'setting':
            return LayoutSetting(state)
        case 'list':
            return LayoutList(state)
        case _:
            return ft.Column([ft.Text(state.layout)])

# 設定レイアウトコンポーネント
@ft.component
def LayoutSetting(state):
    async def handle_get_directory_path(e: ft.Event[ft.Button]):
        files = await ft.FilePicker().pick_files(allow_multiple=False, file_type=ft.FilePickerFileType.CUSTOM, allowed_extensions=["zip"],) 
        dir = os.path.dirname(files[0].path)
        state.change_directory(dir)
    
    return ft.Column([
        ft.Text('Setting Layout'),
        ft.Text('任意のXBRL書類zipファイルをクリックすることで、そのフォルダパスがデフォルトディレクトリになります'),
        ft.Button(
            content="Open directory",
            icon=ft.Icons.FOLDER_OPEN,
            on_click=handle_get_directory_path,
        ),
    ])

# デフォルトの書庫内ファイルリストを表示するレイアウトコンポーネント
@ft.component
def LayoutList(state):
    def on_double_tap(e):
        logger.info(f'double tap table content: {e.control.data}')
        # zipファイル内から選択コンテンツのソースを取得してローカルに一時ファイルとして保存して、webbrowserでこのローカルファイルを開く
        filename = e.control.data
        zipfile_path = os.path.join(state.dir, state.current_doc + '.zip')
        zipfile = ZipArchive(zipfile_path)
        source = zipfile.get_source(filename)
        del zipfile
        _, ext = os.path.splitext(filename)
        # .xsdは開けないので.xmlとして開く
        if ext == '.xsd':
            ext = '.xml'
        temp_dir = os.path.join(os.path.dirname(__file__), 'data')
        if not os.path.exists(temp_dir):
            os.makedirs(temp_dir)        
        
        temp_html = os.path.join(temp_dir, 'temp' + ext)
        with open(temp_html, 'wb') as f:
            f.write(source)
        url = f'file://{temp_html}' 
        webbrowser.open(url)
        # 選択行として色を変更するために
        state.change_content(e.control.data)

    def on_select_change(e):
        state.change_content(e.control.data)

    logger.info('--LayoutList--')
    zipfile_path = os.path.join(state.dir, state.current_doc + '.zip')
    zipfile = ZipArchive(zipfile_path)
    files = zipfile.files
    del zipfile
    return ft.Column([
        ft.Text('ダブルクリックでブラウザで表示されます'),
        ftd.DataTable2(
            columns=[
                ftd.DataColumn2(label=ft.Text('id'),fixed_width=50),
                ftd.DataColumn2(label=ft.Text('path')),
            ],
            rows=[
                ftd.DataRow2(
                    cells = [
                        ft.DataCell(content=ft.Text(i)),
                        ft.DataCell(content=ft.Text(files[i], tooltip=files[i])),
                    ],
                    data=files[i],
                    # DataTableはselectedでアクティブ行の背景色が変わる
                    selected=True if files[i] == state.current_content else None,
                    on_select_change = on_select_change,
                    on_double_tap=on_double_tap,
                    expand=True,
                )
                for i in range(len(files)) 
            ],
            expand=True,            
        )
    ])

# これでfletのViewコンポーネントを表示
ft.run(lambda page: page.render_views(AppView))

zip.png

命令型UIで作成したものに比べてコーディング量も減り簡潔になりました。
sidebarのアイテム選択時の色の変更時間も短くなったようです。
また、命令形UIの場合は異なるレイアウトを表示する場合はroutingでViewを変更していたのですが、宣言型であれば条件により上記の設定レイアウトのようにコンポーネント内で別のコンポーネントをパーツとして切り替えることでレイアウトの変更が簡単にできます。

不明な点とトラブルについて

SnackBarの挙動

fletのDataTableは行数が多いと描画に時間がかかります。
時間のかかる処理の前にステータス(SnackBar)に「しばらくお待ちください」等の文字列を出力して、引き続き重い処理をする方法がよくわかりません。
AlertDialogとSnackBarはpage.show_dialog()で描画されるように仕様変更されたようです。しばらくはモジュールにpageオブジェクトを渡す方法がわからなくてdeclarativeでSnackBarを表示できなかったのですが、ドキュメントの片隅にft.context.pageをモジュール内で使用できることが記載されておりSnackBarも表示できるようにはなりました。しかし、ft.context.page.show_dialog(snamckbar)でステータスに文字列の出力はできるものの、重い処理が終了して他のコントロールと共に描画されてしまいます。asyncioなど非同期処理も試してみましたが思うような動きをしてくれません。よくよく考えてみると、最初にSnackBarを出力するためだけにstatus変更をしてSnackBarを表示してから重い処理を実行するためのstatus変更と2段階の処理が必要なのかもしれません。

描画の重いコントロールの再描画に失敗することがある

上記のファイラーアプリにグラフ表示するためのChartと他のコントロールを同時に表示するレイアウトを追加して試してみましたが、最初のうちはうまく動くのですが書類変更を繰り返しているうちに再描画がされなくなってしまいます。
書類クリックでコンソールにはログが出力されるのですがUIは固まってしまい以降反応しなくなります。
このような不具合は、3つ以上のDataTable2を同一レイアウトに表示した場合にも起こるようで、メモリの問題かもしれません。

実行環境

OS: Windows11Pro, MacOS26.3(M1)
python 3.13, 3.14
flet 0.80.5
flet-charts 0.80.5
flet-cli 0.80.5
flet-datatable2 0.80.5
flet-desktop 0.80.5
flet-web 0.80.5
flet-webview 0.80.5

  1. Introducing Declarative UI in Flet 2

  2. From Imperative to Declarative in Flet: Migrating a Simple CRUD “User Manager”

  3. Pythonだけで"ほぼReact"な宣言的UIアプリが作れるようになったぞ!【Flet1.0beta】

  4. 【完全解説】Flet1.0でPythonGUI開発が激変!宣言的UIへの大転換とAlpha版での実装テクニック、破壊的変更への対処法からWebAssembly対応まで徹底解説

  5. declarative examples collection

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?