日韩无码专区无码一级三级片|91人人爱网站中日韩无码电影|厨房大战丰满熟妇|AV高清无码在线免费观看|另类AV日韩少妇熟女|中文日本大黄一级黄色片|色情在线视频免费|亚洲成人特黄a片|黄片wwwav色图欧美|欧亚乱色一区二区三区

RELATEED CONSULTING
相關(guān)咨詢
選擇下列產(chǎn)品馬上在線溝通
服務(wù)時(shí)間:8:30-17:00
你可能遇到了下面的問(wèn)題
關(guān)閉右側(cè)工具欄

新聞中心

這里有您想知道的互聯(lián)網(wǎng)營(yíng)銷(xiāo)解決方案
Textual:為Python增加漂亮的文本用戶界面(TUI)

Python 在 Linux 上有像 TkInter 這樣的優(yōu)秀 GUI(圖形用戶界面)開(kāi)發(fā)庫(kù),但如果你不能運(yùn)行圖形應(yīng)用程序怎么辦?

成都創(chuàng)新互聯(lián)專(zhuān)業(yè)為企業(yè)提供碭山網(wǎng)站建設(shè)、碭山做網(wǎng)站、碭山網(wǎng)站設(shè)計(jì)、碭山網(wǎng)站制作等企業(yè)網(wǎng)站建設(shè)、網(wǎng)頁(yè)設(shè)計(jì)與制作、碭山企業(yè)網(wǎng)站模板建站服務(wù),十余年碭山做網(wǎng)站經(jīng)驗(yàn),不只是建網(wǎng)站,更提供有價(jià)值的思路和整體網(wǎng)絡(luò)服務(wù)。

文本終端,并非只在 Linux 上有,而且 BSD 和其它的出色的類(lèi) Unix 操作系統(tǒng)上也有。如果你的代碼是用 Python 編寫(xiě)的,你應(yīng)該使用 Textual 來(lái)幫助你編寫(xiě) TUI(文本用戶界面)。在這個(gè)快速介紹中,我將向你展示兩個(gè)你可以用 Textual 做的示例,并且介紹它未來(lái)可能的應(yīng)用方向。

所以 Textual 是什么?

Textual 是一個(gè)為 Python 構(gòu)建的快速應(yīng)用程序開(kāi)發(fā)框架,由 Textualize.io 構(gòu)建。它可以讓你用簡(jiǎn)單的 Python API 構(gòu)建復(fù)雜的用戶界面,并運(yùn)行在終端或網(wǎng)絡(luò)瀏覽器上!

你需要的跟進(jìn)這個(gè)教程的工具

你需要有以下條件:

  1. 具備基礎(chǔ)的編程經(jīng)驗(yàn),最好熟練使用 Python。
  2. 理解基礎(chǔ)的面向?qū)ο蟾拍睿热珙?lèi)和繼承。
  3. 一臺(tái)安裝了 Linux 與 Python 3.9+ 的機(jī)器
  4. 一款好的編輯器(Vim 或者 PyCharm 是不錯(cuò)的選擇)

我盡可能簡(jiǎn)單化代碼,以便你能輕松理解。此外,我強(qiáng)烈建議你下載代碼,或至少按照接下來(lái)的說(shuō)明安裝相關(guān)程序。

安裝步驟

首先創(chuàng)建一個(gè)虛擬環(huán)境:

python3 -m venv ~/virtualenv/Textualize

現(xiàn)在,你可以克隆 Git 倉(cāng)庫(kù)并創(chuàng)建一個(gè)可以編輯的發(fā)布版本:

. ~/virtualenv/Textualize/bin/activate
pip install --upgrade pip
pip install --upgrade wheel
pip install --upgrade build
pip install --editable .

或者直接從 Pypi.org 安裝:

. ~/virtualenv/Textualize/bin/activate
pip install --upgrade KodegeekTextualize

我們的首個(gè)程序:日志瀏覽器

這個(gè) 日志瀏覽器 就是一款簡(jiǎn)單的應(yīng)用,能執(zhí)行用戶 PATH

以下是該應(yīng)用的代碼:

import shutil
from textual import on
from textual.app import ComposeResult, App
from textual.widgets import Footer, Header, Button, SelectionList
from textual.widgets.selection_list import Selection
from textual.screen import ModalScreen
# Operating system commands are hardcoded
OS_COMMANDS = {
    "LSHW": ["lshw", "-json", "-sanitize", "-notime", "-quiet"],
    "LSCPU": ["lscpu", "--all", "--extended", "--json"],
    "LSMEM": ["lsmem", "--json", "--all", "--output-all"],
    "NUMASTAT": ["numastat", "-z"]
}
class LogScreen(ModalScreen):
    # ... Code of the full separate screen omitted, will be explained next
    def __init__(self, name = None, ident = None, classes = None, selections = None):
        super().__init__(name, ident, classes)
        pass
class OsApp(App):
    BINDINGS = [
        ("q", "quit_app", "Quit"),
    ]
    CSS_PATH = "os_app.tcss"
    ENABLE_COMMAND_PALETTE = False  # Do not need the command palette
    def action_quit_app(self):
        self.exit(0)
    def compose(self) -> ComposeResult:
        # Create a list of commands, valid commands are assumed to be on the PATH variable.
        selections = [Selection(name.title(), ' '.join(cmd), True) for name, cmd in OS_COMMANDS.items() if shutil.which(cmd[0].strip())]
        yield Header(show_clock=False)
        sel_list = SelectionList(*selections, id='cmds')
        sel_list.tooltip = "Select one more more command to execute"
        yield sel_list
        yield Button(f"Execute {len(selections)} commands", id="exec", variant="primary")
        yield Footer()
    @on(SelectionList.SelectedChanged)
    def on_selection(self, event: SelectionList.SelectedChanged) -> None:
        button = self.query_one("#exec", Button)
        selections = len(event.selection_list.selected)
        if selections:
            button.disabled = False
        else:
            button.disabled = True
        button.label = f"Execute {selections} commands"
    @on(Button.Pressed)
    def on_button_click(self):
        selection_list = self.query_one('#cmds', SelectionList)
        selections = selection_list.selected
        log_screen = LogScreen(selections=selections)
        self.push_screen(log_screen)
def main():
    app = OsApp()
    app.title = f"Output of multiple well known UNIX commands".title()
    app.sub_title = f"{len(OS_COMMANDS)} commands available"
    app.run()
if __name__ == "__main__":
    main()

現(xiàn)在我們逐條梳理一下程序的代碼:

  1. 每個(gè)應(yīng)用都擴(kuò)展自 App 類(lèi)。其中最重要的有 compose 與 mount 等方法。但在當(dāng)前應(yīng)用中,我們只實(shí)現(xiàn)了 compose。
  2. 在 compose 方法中,你會(huì)返回一系列 組件Widget,并按順序添加到主屏幕中。每一個(gè)組件都有定制自身外觀的選項(xiàng)。
  3. 你可以設(shè)定單字母的 綁定binding,比如此處我們?cè)O(shè)定了 q 鍵來(lái)退出應(yīng)用(參見(jiàn) action_quit_app 函數(shù)和 BINDINGS 列表)。
  4. 利用 SelectionList 組件,我們展示了待運(yùn)行的命令列表。然后,你可以通過(guò) @on(SelectionList.SelectedChanged) 注解以及 on_selection 方法告知應(yīng)用獲取所選的內(nèi)容。
  5. 對(duì)于無(wú)選定元素的應(yīng)對(duì)很重要,我們會(huì)根據(jù)運(yùn)行的命令數(shù)量來(lái)決定是否禁用 “exec” 按鈕。
  6. 我們使用類(lèi)似的監(jiān)聽(tīng)器( @on(Button.Pressed) )來(lái)執(zhí)行命令。我們做的就是將我們的選擇送到一個(gè)新的屏幕,該屏幕會(huì)負(fù)責(zé)執(zhí)行命令并收集結(jié)果。

你注意到 CSS_PATH = "os_app.tcss" 這個(gè)變量了嗎?Textual 允許你使用 CSS 來(lái)控制單個(gè)或多個(gè)組件的外觀(色彩、位置、尺寸):

Screen {
        layout: vertical;
}
Header {
        dock: top;
}
Footer {
        dock: bottom;
}
SelectionList {
        padding: 1;
        border: solid $accent;
        width: 1fr;
        height: 80%;
}
Button {
        width: 1fr
}

引自 Textual 官方網(wǎng)站:

Textual 中使用的 CSS 是互聯(lián)網(wǎng)上常見(jiàn) CSS 的簡(jiǎn)化版本,容易上手。

這真是太棒了,只需要用一哥獨(dú)立的 樣式表,就可以輕松調(diào)整應(yīng)用的樣式。

好,我們現(xiàn)在來(lái)看看如何在新屏幕上展示結(jié)果。

在新屏幕上展示結(jié)果

以下是在新屏幕上處理輸出的代碼:

import asyncio
from typing import List
from textual import on, work
from textual.reactive import reactive
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Log
from textual.worker import Worker
from textual.app import ComposeResult

class LogScreen(ModalScreen):
    count = reactive(0)
    MAX_LINES = 10_000
    ENABLE_COMMAND_PALETTE = False
    CSS_PATH = "log_screen.tcss"

    def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            selections: List = None
    ):
        super().__init__(name, ident, classes)
        self.selections = selections

    def compose(self) -> ComposeResult:
        yield Label(f"Running {len(self.selections)} commands")
        event_log = Log(
            id='event_log',
            max_lines=LogScreen.MAX_LINES,
            highlight=True
        )
        event_log.loading = True
        yield event_log
        button = Button("Close", id="close", variant="success")
        button.disabled = True
        yield button

    async def on_mount(self) -> None:
        event_log = self.query_one('#event_log', Log)
        event_log.loading = False
        event_log.clear()
        lst = '\n'.join(self.selections)
        event_log.write(f"Preparing:\n{lst}")
        event_log.write("\n")

        for command in self.selections:
            self.count += 1
            self.run_process(cmd=command)

    def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
        if self.count == 0:
            button = self.query_one('#close', Button)
            button.disabled = False
        self.log(event)

    @work(exclusive=False)
    async def run_process(self, cmd: str) -> None:
        event_log = self.query_one('#event_log', Log)
        event_log.write_line(f"Running: {cmd}")
        # Combine STDOUT and STDERR output
        proc = await asyncio.create_subprocess_shell(
            cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT
        )
        stdout, _ = await proc.communicate()
        if proc.returncode != 0:
            raise ValueError(f"'{cmd}' finished with errors ({proc.returncode})")
        stdout = stdout.decode(encoding='utf-8', errors='replace')
        if stdout:
            event_log.write(f'\nOutput of "{cmd}":\n')
            event_log.write(stdout)
        self.count -= 1

    @on(Button.Pressed, "#close")
    def on_button_pressed(self, _) -> None:
        self.app.pop_screen()

你會(huì)注意到:

  1. LogScreen 類(lèi)擴(kuò)展自 ModalScreen 類(lèi), 該類(lèi)負(fù)責(zé)處理模態(tài)模式的屏幕。
  2. 這個(gè)屏幕同樣有一個(gè) compose 方法,我們?cè)谶@里添加了組件以展示 Unix 命令的內(nèi)容。
  3. 我們創(chuàng)建了一個(gè)叫做 mount 的新方法。一旦你用 compose 編排好組件,你就可以運(yùn)行代碼來(lái)獲取數(shù)據(jù),并再進(jìn)一步定制它們的外觀。
  4. 我們使用 asyncio 運(yùn)行命令,這樣我們就能讓 TUI 主工作線程在每個(gè)命令的結(jié)果出來(lái)時(shí)就及時(shí)更新內(nèi)容。
  5. 對(duì)于“工作線程”,請(qǐng)注意 run_process 方法上的 @work(exclusive=False) 注解,該方法用于運(yùn)行命令并捕獲 STDOUT + STDERR 輸出。使用 工作線程 來(lái)管理并發(fā)并不復(fù)雜,盡管它們?cè)谑謨?cè)中確實(shí)有專(zhuān)門(mén)的章節(jié)。這主要是因?yàn)檫\(yùn)行的外部命令可能會(huì)執(zhí)行很長(zhǎng)時(shí)間。
  6. 在 run_process 中,我們通過(guò)調(diào)用 write 以命令的輸出內(nèi)容來(lái)更新 event_log
  7. 最后,on_button_pressed 把我們帶回到前一屏幕(從堆棧中移除屏幕)。

這個(gè)小應(yīng)用向你展示了如何一份不到 200 行的代碼來(lái)編寫(xiě)一個(gè)簡(jiǎn)單的前端,用來(lái)運(yùn)行非 Python 代碼。

現(xiàn)在我們來(lái)看一個(gè)更復(fù)雜的例子,這個(gè)例子用到了我們還未探索過(guò)的 Textual 的新特性。

示例二:展示賽事成績(jī)的表格

通過(guò) Textual 創(chuàng)建的表格應(yīng)用

通過(guò) Textual 創(chuàng)建的表格應(yīng)用

本示例將展示如何使用 DataTable 組件在表格中展示賽事成績(jī)。你能通過(guò)這個(gè)應(yīng)用實(shí)現(xiàn):

  • 通過(guò)列來(lái)排序表格
  • 選擇表格中的行,完整窗口展示賽事細(xì)節(jié),我們將使用我們?cè)谌罩緸g覽器中看到的 “推送屏幕” 技巧。
  • 能進(jìn)行表格搜索,查看選手詳情,或執(zhí)行其他操作如退出應(yīng)用。

下面,我們來(lái)看看應(yīng)用代碼:

#!/usr/bin/env python
"""
Author: Jose Vicente Nunez
"""
from typing import Any, List
from rich.style import Style
from textual import on
from textual.app import ComposeResult, App
from textual.command import Provider
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable, Footer, Header

MY_DATA = [
    ("level", "name", "gender", "country", "age"),
    ("Green", "Wai", "M", "MYS", 22),
    ("Red", "Ryoji", "M", "JPN", 30),
    ("Purple", "Fabio", "M", "ITA", 99),
    ("Blue", "Manuela", "F", "VEN", 25)
]
class DetailScreen(ModalScreen):
    ENABLE_COMMAND_PALETTE = False
    CSS_PATH = "details_screen.tcss"
    def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            row: List[Any] | None = None,
    ):
        super().__init__(name, ident, classes)
        # Rest of screen code will be show later

class CustomCommand(Provider):
    def __init__(self, screen: Screen[Any], match_style: Style | None = None):
        super().__init__(screen, match_style)
        self.table = None
        # Rest of provider code will be show later
class CompetitorsApp(App):
    BINDINGS = [
        ("q", "quit_app", "Quit"),
    ]
    CSS_PATH = "competitors_app.tcss"
    # Enable the command palette, to add our custom filter commands
    ENABLE_COMMAND_PALETTE = True
    # Add the default commands and the TablePopulateProvider to get a row directly by name
    COMMANDS = App.COMMANDS | {CustomCommand}
    def action_quit_app(self):
        self.exit(0)
    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        table = DataTable(id=f'competitors_table')
        table.cursor_type = 'row'
        table.zebra_stripes = True
        table.loading = True
        yield table
        yield Footer()
    def on_mount(self) -> None:
        table = self.get_widget_by_id(f'competitors_table', expect_type=DataTable)
        columns = [x.title() for x in MY_DATA[0]]
        table.add_columns(*columns)
        table.add_rows(MY_DATA[1:])
        table.loading = False
        table.tooltip = "Select a row to get more details"
    @on(DataTable.HeaderSelected)
    def on_header_clicked(self, event: DataTable.HeaderSelected):
        table = event.data_table
        table.sort(event.column_key)
    @on(DataTable.RowSelected)
    def on_row_clicked(self, event: DataTable.RowSelected) -> None:
        table = event.data_table
        row = table.get_row(event.row_key)
        runner_detail = DetailScreen(row=row)
        self.show_detail(runner_detail)
    def show_detail(self, detailScreen: DetailScreen):
        self.push_screen(detailScreen)
def main():
    app = CompetitorsApp()
    app.title = f"Summary".title()
    app.sub_title = f"{len(MY_DATA)} users"
    app.run()
if __name__ == "__main__":
    main()

有哪些部分值得我們關(guān)注呢?

  1. compose 方法中添加了 表頭,“命令面板” 就位于此處,我們的表格(DataTable)也在這里。表格數(shù)據(jù)在 mount 方法中填充。
  2. 我們?cè)O(shè)定了預(yù)期的綁定(BINDINGS),并指定了外部的 CSS 文件來(lái)設(shè)置樣式(CSS_PATH)。
  3. 默認(rèn)情況下,我們無(wú)需任何設(shè)置便能使用 命令面板,但在此我們顯式啟用了它(ENABLE_COMMAND_PALETTE = True)。
  4. 我們的應(yīng)用有一個(gè)自定義表格搜索功能。當(dāng)用戶輸入一名選手的名字后,應(yīng)用會(huì)顯示可能的匹配項(xiàng),用戶可以點(diǎn)擊匹配項(xiàng)查看該選手的詳細(xì)信息。這需要告訴應(yīng)用我們有一個(gè)定制的命令提供者(COMMANDS = App.COMMANDS | {CustomCo_ mmand}),即類(lèi) CustomCommand(Provider)。
  5. 如果用戶點(diǎn)擊了表頭,表格內(nèi)容會(huì)按照該列進(jìn)行排序。這是通過(guò) on_header_clicked 方法實(shí)現(xiàn)的,該方法上具有 @on(DataTable.HeaderSelected) 注解。
  6. 類(lèi)似地,當(dāng)選中表格中的一行時(shí), on_row_clicked 方法會(huì)被調(diào)用,這得益于它擁有 @on(DataTable.RowSelected) 注解。當(dāng)方法接受選中的行后,它會(huì)推送一個(gè)新的屏幕,顯示選中行的詳細(xì)信息(class DetailScreen(ModalScreen))。

現(xiàn)在,我們?cè)敿?xì)地探討一下如何顯示選手的詳細(xì)信息。

利用多屏展示復(fù)雜視圖

當(dāng)用戶選擇表格中的一行,on_row_clicked 方法就會(huì)被調(diào)用。它收到的是一個(gè) DataTable.RowSelected 類(lèi)型的事件。從這里我們會(huì)用選中的行的內(nèi)容構(gòu)建一個(gè) DetailScreen(ModalScreen) 類(lèi)的實(shí)例:

from typing import Any, List
from textual import on
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Button, MarkdownViewer
MY_DATA = [
    ("level", "name", "gender", "country", "age"),
    ("Green", "Wai", "M", "MYS", 22),
    ("Red", "Ryoji", "M", "JPN", 30),
    ("Purple", "Fabio", "M", "ITA", 99),
    ("Blue", "Manuela", "F", "VEN", 25)
]
class DetailScreen(ModalScreen):
    ENABLE_COMMAND_PALETTE = False
    CSS_PATH = "details_screen.tcss"
    def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            row: List[Any] | None = None,
    ):
        super().__init__(name, ident, classes)
        self.row: List[Any] = row
    def compose(self) -> ComposeResult:
        self.log.info(f"Details: {self.row}")
        columns = MY_DATA[0]
        row_markdown = "\n"
        for i in range(0, len(columns)):
            row_markdown += f"* **{columns[i].title()}:** {self.row[i]}\n"
        yield MarkdownViewer(f"""## User details:
        {row_markdown}
        """)
        button = Button("Close", variant="primary", id="close")
        button.tooltip = "Go back to main screen"
        yield button
    @on(Button.Pressed, "#close")
    def on_button_pressed(self, _) -> None:
        self.app.pop_screen()

這個(gè)類(lèi)的職責(zé)很直接:

  1. compose 方法取得此行數(shù)據(jù),并利用一個(gè) 支持 Markdown 渲染的組件 來(lái)展示內(nèi)容。它的便利之處在于,它會(huì)為我們自動(dòng)生成一個(gè)內(nèi)容目錄。
  2. 當(dāng)用戶點(diǎn)擊 “close” 后,方法 on_button_pressed 會(huì)引導(dǎo)應(yīng)用回到原始屏幕。注解 @on(Button.Pressed, "#close") 用來(lái)接收按鍵被點(diǎn)擊的事件。

最后,我們來(lái)詳細(xì)講解一下那個(gè)多功能的搜索欄(也叫做命令面板)。

命令面板的搜索功能

任何使用了表頭的 Textual 應(yīng)用都默認(rèn)開(kāi)啟了 命令面板。有意思的是,你可以在 CompetitorsApp 類(lèi)中添加自定義的命令,這會(huì)增加到默認(rèn)命令集之上:

COMMANDS = App.COMMANDS | {CustomCommand}

然后是執(zhí)行大部分任務(wù)的 CustomCommand(Provider) 類(lèi):

from functools import partial
from typing import Any, List
from rich.style import Style
from textual.command import Provider, Hit
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable
from textual.app import App
class CustomCommand(Provider):

    def __init__(self, screen: Screen[Any], match_style: Style | None = None):
        super().__init__(screen, match_style)
        self.table = None
    async def startup(self) -> None:
        my_app = self.app
        my_app.log.info(f"Loaded provider: CustomCommand")
        self.table = my_app.query(DataTable).first()
    async def search(self, query: str) -> Hit:
        matcher = self.matcher(query)
        my_app = self.screen.app
        assert isinstance(my_app, CompetitorsApp)
        my_app.log.info(f"Got query: {query}")
        for row_key in self.table.rows:
            row = self.table.get_row(row_key)
            my_app.log.info(f"Searching {row}")
            searchable = row[1]
            score = matcher.match(searchable)
            if score > 0:
                runner_detail = DetailScreen(row=row)
                yield Hit(
                    score,
                    matcher.highlight(f"{searchable}"),
                    partial(my_app.show_detail, runner_detail),
                    help=f"Show details about {searchable}"
                )
class DetailScreen(ModalScreen):
        def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            row: List[Any] | None = None,
    ):
        super().__init__(name, ident, classes)
        # Code of this class explained on the previous section
class CompetitorsApp(App):
    # Add the default commands and the TablePopulateProvider to get a row directly by name
    COMMANDS = App.COMMANDS | {CustomCommand}
    # Most of the code shown before, only displaying relevant code
    def show_detail(self, detailScreen: DetailScreen):
        self.push_screen(detailScreen)
  1. 所有繼承自 Provider 的類(lèi)需實(shí)現(xiàn) search 方法。在我們的例子中,我們還覆蓋了 startup 方法,為了獲取到我們應(yīng)用表格(和其內(nèi)容)的引用,這里使用到了 App.query(DataTable).first()。在類(lèi)的生命周期中, startup 方法只會(huì)被調(diào)用一次。
  2. 在 search 方法內(nèi),我們使用 Provider.matcher 對(duì)每個(gè)表格行的第二列(即名字)進(jìn)行模糊搜索,以與用戶在 TUI 中輸入的詞條進(jìn)行比較。matcher.match(searchable) 返回一個(gè)整型的評(píng)分,大于零說(shuō)明匹配成功。
  3. 在 search 方法中,如果評(píng)分大于零,則返回一個(gè) Hit 對(duì)象,以告知命令面板搜索查詢是否成功。
  4. 每個(gè) Hit 都有以下信息:評(píng)分(用于在命令面板中對(duì)匹配項(xiàng)排序)、高亮顯示的搜索詞、一個(gè)可調(diào)用對(duì)象的引用(在我們的案例中,它是一個(gè)可以將表格行推送到新屏幕的函數(shù))。
  5. Provider 類(lèi)的所有方法都是異步的。這使你能釋放主線程,只有當(dāng)響應(yīng)準(zhǔn)備好后才返回結(jié)果,這個(gè)過(guò)程不會(huì)凍結(jié)用戶界面。

理解了這些信息,我們就可以現(xiàn)在展示賽手的詳細(xì)信息了。

盡管這個(gè)架構(gòu)的追蹤功能相對(duì)直觀,但是組件間傳遞的消息復(fù)雜性不可忽視。幸運(yùn)的是,Textual 提供了有效的調(diào)試工具幫助我們理解背后的工作原理。

Textual 應(yīng)用的問(wèn)題排查

對(duì)于 Python 的 Textual 應(yīng)用進(jìn)行 調(diào)試 相較而言更具挑戰(zhàn)性。這是因?yàn)槠渲杏幸恍┎僮骺赡苁钱惒降模诮鉀Q組件問(wèn)題時(shí)設(shè)置斷點(diǎn)可能頗為復(fù)雜。

根據(jù)具體情況,你可以使用一些工具。但首先,確保你已經(jīng)安裝了 textual 的開(kāi)發(fā)工具:

pip install textual-dev==1.3.0

確保你能捕捉到正確的按鍵

不確定 Textual 應(yīng)用是否能捕捉到你的按鍵操作?運(yùn)行 keys 應(yīng)用:

textual keys

這讓你能夠驗(yàn)證一下你的按鍵組合,并確認(rèn)在 Textual 中產(chǎn)生了哪些事件。

圖片比千言萬(wàn)語(yǔ)更直觀

如果說(shuō)你在布局設(shè)計(jì)上遇到了問(wèn)題,想向他人展示你當(dāng)前的困境,Textual 為你的運(yùn)行應(yīng)用提供了截圖功能:

textual run --screenshot 5 ./kodegeek_textualize/log_scroller.py

就像你所看到的,我是通過(guò)這種方式為這篇教程創(chuàng)建了插圖。

捕獲事件并輸出定制消息

在 Textual 中,每一個(gè)應(yīng)用實(shí)例都有一個(gè)日志記錄器,可以使用如下方式訪問(wèn):

my_app = self.screen.app
my_app.log.info(f"Loaded provider: CustomCommand")

想要查看這些消息,首先需要開(kāi)啟一個(gè)控制臺(tái):

. ~/virtualenv/Textualize/bin/activate
textual console

然后在另一個(gè)終端運(yùn)行你的應(yīng)用程序:

. ~/virtualenv/Textualize/bin/activate
textual run --dev ./kodegeek_textualize/log_scroller.py

在運(yùn)行控制臺(tái)的終端中,你可以看到實(shí)時(shí)的事件和消息輸出:

▌Textual Development Console v0.46.0
▌Run a Textual app with textual run --dev my_app.py to connect.
▌Press Ctrl+C to quit.
─────────────────────────────────────────────────────────────────────────────── Client '127.0.0.1' connected ────────────────────────────────────────────────────────────────────────────────
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2188
Connected to devtools ( ws://127.0.0.1:8081 )
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2192
---
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2194
driver=
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2195
loop=<_UnixSelectorEventLoop running=True closed=False debug=False>
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2196
features=frozenset({'debug', 'devtools'})
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2228
STARTED FileMonitor({PosixPath('/home/josevnz/TextualizeTutorial/docs/Textualize/kodegeek_textualize/os_app.tcss')})
[20:29:43] EVENT

此外,以開(kāi)發(fā)者模式運(yùn)行的另一大好處是,如果你更改了 CSS,應(yīng)用會(huì)嘗試重新渲染,而無(wú)需重啟程序。

如何編寫(xiě)單元測(cè)試

為你全新開(kāi)發(fā)的 Textual 應(yīng)用編寫(xiě) 單元測(cè)試,應(yīng)該如何操作呢?

在 官方文檔 展示了幾種用于測(cè)試我們應(yīng)用的方式。

我將采用 unittest 進(jìn)行測(cè)試。為了處理異步例程,我們會(huì)需要特別的類(lèi) unittest.IsolatedAsyncioTestCase :

import unittest
from textual.widgets import Log, Button
from kodegeek_textualize.log_scroller import OsApp
class LogScrollerTestCase(unittest.IsolatedAsyncioTestCase):
    async def test_log_scroller(self):
        app = OsApp()
        self.assertIsNotNone(app)
        async with app.run_test() as pilot:
            # Execute the default commands
            await pilot.click(Button)
            await pilot.pause()
            event_log = app.screen.query(Log).first()  # We pushed the screen, query nodes from there
            self.assertTrue(event_log.lines)
            await pilot.click("#close")  # Close the new screen, pop the original one
            await pilot.press("q")  # Quit the app by pressing q
if __name__ == '__main__':
    unittest.main()

現(xiàn)在讓我們?cè)敿?xì)看看 test_log_scroller 方法中的操作步驟:

  1. 通過(guò) app.run_test() 獲取一個(gè) Pilot 實(shí)例。然后點(diǎn)擊主按鈕,運(yùn)行包含默認(rèn)指令的查詢,隨后等待所有事件的處理。
  2. 從我們新推送出的屏幕中獲取 Log,確保我們已獲得幾行返回的內(nèi)容,即它并非空的。
  3. 關(guān)閉新屏幕并重新呈現(xiàn)舊屏幕。
  4. 最后,按下 q,退出應(yīng)用。

可以測(cè)試表格嗎?

import unittest
from textual.widgets import DataTable, MarkdownViewer
from kodegeek_textualize.table_with_detail_screen import CompetitorsApp
class TableWithDetailTestCase(unittest.IsolatedAsyncioTestCase):
    async def test_app(self):
        app = CompetitorsApp()
        self.assertIsNotNone(app)
        async with app.run_test() as pilot:
            """
            Test the command palette
            """
            await pilot.press("ctrl+\\")
            for char in "manuela".split():
                await pilot.press(char)
            await pilot.press("enter")
            markdown_viewer = app.screen.query(MarkdownViewer).first()
            self.assertTrue(markdown_viewer.document)
            await pilot.click("#close")  # Close the new screen, pop the original one
            """
            Test the table
            """
            table = app.screen.query(DataTable).first()
            coordinate = table.cursor_coordinate
            self.assertTrue(table.is_valid_coordinate(coordinate))
            await pilot.press("enter")
            await pilot.pause()
            markdown_viewer = app.screen.query(MarkdownViewer).first()
            self.assertTrue(markdown_viewer)
            # Quit the app by pressing q
            await pilot.press("q")
if __name__ == '__main__':
    unittest.main()

如果你運(yùn)行所有的測(cè)試,你將看到如下類(lèi)似的輸出:

(Textualize) [josevnz@dmaf5 Textualize]$ python -m unittest tests/*.py
..
----------------------------------------------------------------------
Ran 2 tests in 2.065s

OK

這是測(cè)試 TUI 的一個(gè)不錯(cuò)的方式,對(duì)吧?

打包 Textual 應(yīng)用

打包 Textual 應(yīng)用與打包常規(guī) Python 應(yīng)用并沒(méi)有太大區(qū)別。你需要記住,需要包含那些控制應(yīng)用外觀的 CSS 文件:

. ~/virtualenv/Textualize/bin/activate
python -m build
pip install dist/KodegeekTextualize-*-py3-none-any.whl

這個(gè)教程的 pyproject.toml 文件是一個(gè)打包應(yīng)用的良好起點(diǎn),告訴你需要做什么。

[build-system]
requires = [
    "setuptools >= 67.8.0",
    "wheel>=0.42.0",
    "build>=1.0.3",
    "twine>=4.0.2",
    "textual-dev>=1.2.1"
]
build-backend = "setuptools.build_meta"
[project]
name = "KodegeekTextualize"
version = "0.0.3"
authors = [
    {name = "Jose Vicente Nunez", email = "kodegeek.com@protonmail.com"},
]
description = "Collection of scripts that show how to use several features of textualize"
readme = "README.md"
requires-python = ">=3.9"
keywords = ["running", "race"]
classifiers = [
    "Environment :: Console",
    "Development Status :: 4 - Beta",
    "Programming Language :: Python :: 3",
    "Intended Audience :: End Users/Desktop",
    "Topic :: Utilities"
]
dynamic = ["dependencies"]
[project.scripts]
log_scroller = "kodegeek_textualize.log_scroller:main"
table_detail = "kodegeek_textualize.table_with_detail_screen:main"
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
exclude = ["test*"]
[tool.setuptools.package-data]
kodegeek_textualize = ["*.txt", "*.tcss", "*.csv"]
img = ["*.svg"]
[tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]}

未來(lái)計(jì)劃

這個(gè)簡(jiǎn)短的教程只覆蓋了 Textual 的部分方面。還有很多需要探索和學(xué)習(xí)的內(nèi)容:

  • 強(qiáng)烈建議你查看 官方教程。有大量的示例和指向參考 API 的鏈接。
  • Textual 可以使用來(lái)自 Rich 項(xiàng)目的組件,這個(gè)項(xiàng)目是一切的起源。我認(rèn)為其中一些甚至可能所有這些組件在某些時(shí)候都會(huì)合并到 Textual 中。Textual 框架對(duì)于使用高級(jí) API 的復(fù)雜應(yīng)用更能勝任,但 Rich 也有很多漂亮的功能。
  • 創(chuàng)建你自己的組件!同樣,在設(shè)計(jì) TUI 時(shí),拿一張紙,畫(huà)出你希望這些組件如何布局的,這會(huì)為你后期省去很多時(shí)間和麻煩。
  • 調(diào)試 Python 應(yīng)用可能會(huì)有點(diǎn)復(fù)雜。有時(shí)你可能需要 混合使用不同的工具 來(lái)找出應(yīng)用的問(wèn)題所在。
  • 異步 IO 是一個(gè)復(fù)雜的話題,你應(yīng)該 閱讀開(kāi)發(fā)者文檔 來(lái)了解更多可能的選擇。
  • Textual 被其他項(xiàng)目所使用。其中一個(gè)非常易于使用的項(xiàng)目是 Trogon。它會(huì)讓你的 CLI 可以自我發(fā)現(xiàn)。
  • Textual-web 是個(gè)很有前景的項(xiàng)目,能讓你在瀏覽器上運(yùn)行 Textual 應(yīng)用。盡管它不如 Textual 成熟,但它的進(jìn)化速度非???。
  • 最后,查看這些外部項(xiàng)目。在項(xiàng)目組合中有許多有用的開(kāi)源應(yīng)用。

新聞標(biāo)題:Textual:為Python增加漂亮的文本用戶界面(TUI)
URL鏈接:http://m.5511xx.com/article/cdoeocg.html