mcpField
B
building-qt-apps

building-qt-apps

github.com/quick-brown-foxxx開発1104 回インストール8 週間前に更新
リポジトリ

building-qt-apps について

building-qt-appsは、PySide6でQtアプリを開発するPython開発者向けのスキルです。非同期処理、階層化設計、QML、デスクトップ連携、テストの手法を扱い、イベントループをブロックせず、ロジックをテスト可能に保つことを重視します。

主な機能

  1. 01PySide6とqasyncのパターン
  2. 02Manager–Service–Wrapperの階層構成
  3. 03型付きシグナルとスロット
  4. 04QMLのViewModelアーキテクチャ
  5. 05pytest-qtによるQtコンポーネントのテスト

活用シーン

  1. 01Python製Qtアプリの構造設計
  2. 02イベントループを止めない非同期処理の統合
  3. 03QMLビューとアプリロジックの分離
  4. 04Qtウィジェットやコンポーネントのテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/quick-brown-foxxx/coding_rules_python --skill building-qt-apps

インストール後、Claude Code で/building-qt-appsを入力して読み込まれたか確認します。

詳細

Qt アプリの構築

前提条件

このスキルは myai の architecting-changes と engineering-principles を拡張します。まずそれらを読み込んでください。using-my-skills と engineering-principles は、myai のブートストラップですでに読み込まれているものとします。

一般的なアーキテクチャの原則とエンジニアリングの考え方については、myai の architecting-changes と engineering-principles を参照してください。このスキルでは、Python 固有の Qt パターン、すなわち PySide6 + qasync、Manager→Service→Wrapper のレイヤリング、シグナル/スロット、QML の統合、XDG デスクトップポータル、pytest-qt を使用した Qt 固有のテストのみを扱います。

Qt アプリでは、非同期処理の統合に qasync を使用した PySide6 を使います。アーキテクチャは Manager → Service → Wrapper のレイヤリングに従います。イベントループをブロックしないでください。


PySide6 を選ぶ理由

  • LGPL ライセンス(追加の制限なし)
  • 追加のシステム依存関係なし(wheel に同梱)
  • PyQt6 と同じ API で、自由に再配布可能

アーキテクチャ:Manager → Service → Wrapper

依存関係を結び付けるパターン(コンポジションルート)については、building-multi-ui-apps スキルを参照してください。

UI Layer (MainWindow, Dialogs, TrayIcon)
    |  Qt signals/slots
    v
Manager Layer (AudioManager, TranscriptionManager)
    |  orchestrates, emits signals
    v
Service Layer (TranscriptionService, RecordingService)
    |  async operations
    v
Wrapper Layer (WhisperWrapper, SoundcardWrapper)
    |  typed interfaces to third-party libs
    v
Third-Party Libraries
Manager パターン

Manager は処理を調整し、Qt シグナルを発行します。

class TranscriptionManager(QObject):
    transcription_finished = Signal(str)
    transcription_error = Signal(str)
    model_changed = Signal(str)

    def __init__(self, settings: Settings) -> None:
        super().__init__()
        self._service: TranscriptionService | None = None
        self._bridge = QAsyncSignalBridge()

    def transcribe(self, audio_data: np.ndarray) -> bool:
        if not self._service:
            self.transcription_error.emit("Service not initialized")
            return False

        self._bridge.run_async(
            self._service.transcribe(audio_data),
            on_success=self._on_finished,
            on_error=self._on_error,
        )
        return True

    def _on_finished(self, text: str) -> None:
        self.transcription_finished.emit(text)

    def _on_error(self, error: str) -> None:
        self.transcription_error.emit(error)
Wrapper パターン

型付き Wrapper は、型付けされていないサードパーティ API を隔離します。

class WhisperModelWrapper:
    """Typed wrapper for faster-whisper."""

    def __init__(self, model_size: str, device: str = "auto") -> None:
        from faster_whisper import WhisperModel as _WhisperModel
        self._model = _WhisperModel(model_size, device=device)

    def transcribe(self, audio: np.ndarray, language: str | None = None) -> TranscriptionResult:
        segments_gen, info = self._model.transcribe(audio, language=language)
        return TranscriptionResult(
            text="".join(s.text for s in segments_gen),
            language=str(info.language),
        )

qasync による非同期処理の統合(技術プレビュー段階の QtAsyncio より優先)

セットアップ

この起動方法は GUI 専用アプリには適しています。アプリが CLI コマンドもサポートする場合は、len(sys.argv) > 1 で切り替えないでください。building-multi-ui-apps の小規模なトップレベルルーターパターンを使い、Qt の起動処理は GUI エントリポイント内だけに置いてください。

import asyncio
import signal
import qasync
from PySide6.QtWidgets import QApplication

def main() -> int:
    app = QApplication(sys.argv)
    loop = qasync.QEventLoop(app)
    asyncio.set_event_loop(loop)
    signal.signal(signal.SIGINT, signal.SIG_DFL)  # Make Ctrl+C work (Qt blocks it)
    with loop:
        window = MainWindow()
        window.show()
        loop.run_forever()
    return 0
QAsyncSignalBridge

非同期コルーチンを Qt シグナルにブリッジします。

class QAsyncSignalBridge(QObject):
    finished = Signal(object)
    error = Signal(str)

    def run_async(
        self,
        coro: Coroutine[object, None, T],
        on_success: Callable[[T], None] | None = None,
        on_error: Callable[[str], None] | None = None,
    ) -> None:
        async def _wrapped() -> None:
            try:
                result = await coro
                if on_success:
                    on_success(result)
                else:
                    self.finished.emit(result)
            except Exception as e:
                if on_error:
                    on_error(str(e))
                else:
                    self.error.emit(str(e))

        loop = asyncio.get_running_loop()
        self._task = loop.create_task(_wrapped())
ブロッキングライブラリ向けの ThreadPoolExecutor

ライブラリが同期 API しか提供していない場合:

class AsyncRecorder(QObject):
    recording_completed = Signal(np.ndarray)

    def __init__(self) -> None:
        super().__init__()
        self._executor = ThreadPoolExecutor(max_workers=1)

    async def start_recording(self) -> None:
        loop = asyncio.get_running_loop()
        result = await loop.run_in_executor(self._executor, self._sync_record)
        self.recording_completed.emit(result)

主なルール

  1. PyQt ではなく PySide6(LGPL、システム依存関係なし)を使う
  2. イベントループをブロックしない:subprocess.run()、time.sleep()、同期 HTTP を使用しない
  3. qasync は asyncio と Qt のイベントループをブリッジする
  4. ThreadPoolExecutor でブロッキングするサードパーティ API をラップする
  5. 型付けされていないライブラリは 型付き Wrapper でラップし、ruff の banned-api で適用する
  6. シグナルはクラスレベルで定義し、__init__ 内には定義しない
  7. Qt イベントハンドラーは camelCase(ruff N802 を無視)、自作のスロットは snake_case

Ctrl+C とシャットダウン

Qt のイベントループは Python のシグナル処理をブロックするため、Ctrl+C を押しても何も起きないように見えます。対処法:loop.run_forever() の前に signal.signal(signal.SIGINT, signal.SIG_DFL) を設定します。これにより OS が SIGINT を直接処理します(上記のセットアップ例を参照)。

Ctrl+C 時にアプリでクリーンアップが必要な場合(状態の保存、ロックの解放、録画の停止など)は、SIG_DFL の代わりに QApplication.quit() を呼び出すハンドラーを使い、Qt のシャットダウンシーケンスが実行されるようにします。

def _sigint_handler(*_args: object) -> None:
    QApplication.quit()

signal.signal(signal.SIGINT, _sigint_handler)

# Timer lets Python process the signal between Qt events
timer = QTimer()
timer.start(200)
timer.timeout.connect(lambda: None)

サブプロセスのシャットダウンパターンについては、setting-up-python-projects スキルを参照してください。


シグナル/スロットの規約

  • シグナルはクラスレベルで定義する(__init__ 内には定義しない)
  • シグナルは、関係を所有するコンポーネント内で接続する
  • 型付きシグナルを使う:Signal(str)、Signal(float)。PySide6 にジェネリックシグナルのサポートがない場合に限り Signal(object) を使い、# PySide6 limitation: no generic signals コメントを追加する
class AudioManager(QObject):
    volume_changed = Signal(float)
    recording_completed = Signal(np.ndarray)
    recording_failed = Signal(str)

    def __init__(self) -> None:
        super().__init__()
        self._recorder = AsyncRecorder()
        self._recorder.recording_completed.connect(self.recording_completed)

命名規則の例外

Qt の規約に従い、Qt イベントハンドラーには camelCase を使います。

[tool.ruff.lint]
ignore = ["N802"]  # Qt event handlers use camelCase
class CustomWidget(QWidget):
    def mousePressEvent(self, event: QMouseEvent) -> None:  # Qt convention
        ...

    def on_button_clicked(self) -> None:  # Our slots use snake_case
        ...

宣言的なラベル → コールバックパターン

トレイメニュー、ボタンバー、コンテキストメニュー、ツールバー項目など、ラベル付きの固定アクション群を初期化するときは、命令型の addAction/addButton の連続呼び出しは避けてください。代わりに、セットアップメソッドの先頭(型安全なバインド済みメソッド参照で self を使えるスコープ)で、すべての項目をデータとして宣言し、末尾にある汎用ループで構築します。

"SEPARATOR" は Literal のセンチネルです。この位置にほかの文字列を指定すると basedpyright に拒否されるため、センチネルとコールバックの両方が完全に型チェックされます。

from typing import Callable, Final, Literal

_SEPARATOR: Final = "SEPARATOR"
_Entry = tuple[str, Callable[[], None]] | Literal["SEPARATOR"]

class ApplicationTrayIcon(QSystemTrayIcon):
    def __init__(self) -> None:
        super().__init__()
        self.setIcon(QIcon("icon.png"))
        self._setup_menu()

    def _setup_menu(self) -> None:
        entries: list[_Entry] = [
            ("Settings", self._open_settings),
            _SEPARATOR,
            ("Quit", QApplication.quit),
        ]

        menu = QMenu()
        for entry in entries:
            if entry is _SEPARATOR:
                menu.addSeparator()
            else:
                label, cb = entry
                menu.addAction(label, cb)
        self.setContextMenu(menu)

    def _open_settings(self) -> None: ...

項目の追加、削除、並べ替えを行う場所は entries だけです。ループは汎用の定型コードで、変更する必要はありません。self._poen_settings のような入力ミスは、チェック時に basedpyright が検出するため、実行時に予期せぬ問題が起きることはありません。同じパターンは、ボタンバー、コンテキストメニュー、その他のラベル → コールバックのマッピングにも適用できます。


単一インスタンスの強制

class LockManager:
    def __init__(self, lock_path: Path) -> None:
        self._lock_path = lock_path

    def acquire(self) -> Result[None, str]:
        if self._lock_path.exists():
            pid = int(self._lock_path.read_text())
            if self._is_process_running(pid):
                return Err(f"Another instance running (PID {pid})")
            # Stale lock file
        self._lock_path.write_text(str(os.getpid()))
        return Ok(None)

    def release(self) -> None:
        self._lock_path.unlink(missing_ok=True)

キーボードショートカット

TOML 設定でカスタマイズできます。

class ActionID(enum.Enum):
    NEW_PROFILE = "new_profile"
    START_PROFILE = "start_profile"

@dataclass
class ActionShortcut:
    id: str
    label: str
    default_key: str

DEFAULT_SHORTCUTS = (
    ActionShortcut(ActionID.NEW_PROFILE.value, "New Profile", "Ctrl+N"),
    ActionShortcut(ActionID.START_PROFILE.value, "Start Profile", "Return"),
)

ユーザーによる上書き設定は platformdirs.user_config_dir("appname")/shortcuts.toml に保存されます。


設定の管理

型安全な QSettings ラッパー:

class Settings:
    def __init__(self) -> None:
        self._settings = QSettings(APP_NAME, APP_NAME)
        self._init_defaults()

    def get_str(self, key: str, default: str = "") -> str:
        value = self._settings.value(key, default)
        return str(value) if value is not None else default

    def get_int(self, key: str, default: int = 0) -> int:
        value = self._settings.value(key, default)
        return int(value) if value is not None else default

    def set(self, key: str, value: str | int | bool) -> None:
        self._settings.setValue(key, value)

QML との連携:View + ViewModel(MVVM)

QML ベースの UI では、QML は純粋で単純なビューとして扱い、QML を起動せずに単体テストできるよう、すべてのロジックを Python に置きます。**View–ViewModel(MVVM)**の分割を使用します。

  • View = QML のみです。QML にビジネスロジックを置かないでください。function ブロックで実処理を行わず、ナビゲーションの判断も、バインディングにルールも記述しません。状態を描画し、「ユーザーの意図」を通知します(VM のスロットを呼び出します)。
  • ViewModel = QML に公開する QObject です。状態とコマンドのみを持ち、Qt ウィジェットは持ちません。QML がバインドする唯一の対象であり、双方向にテストできる唯一のユニットです(pytest-qt)。
  • Model = VM の下位にある純粋なデータ/ロジックです。Qt への依存がない通常の Python にできます(テスト容易性が最も高くなります)。

MVVM のデータバインディングの代わりにプロパティを使い、@Slot メソッドを「コマンド」として使います。

単一のブリッジ

QML と Qt の間のブリッジは、エンジンのコンテキストに渡す QObject だけです。起動時(コンポジションルート)に一度だけ公開し、setContextProperty の呼び出しをあちこちに分散させないでください。

from PySide6.QtCore import QObject, Property, Signal, Slot

class PageViewModel(QObject):
    pageChanged = Signal(str)

    @Property(str)
    def currentPage(self) -> str: ...          # read-only state
    @currentPage.setter
    def currentPage(self, value: str) -> None: ...

    @Slot(result=str)
    def title(self) -> str: ...

    @Slot(str)                                  # user intent -> logic
    def navigate(self, page: str) -> None: ...

# composition root
engine = QQmlApplicationEngine()
engine.rootContext().setContextProperty("vm", page_vm)
engine.load("Main.qml")

VM は QML を認識しないようにしてください。 QML の内部には触れず、型付きのスロット/プロパティ/シグナルを公開し、QML 側でそれらを UI に変換します。

マルチページ/シェル(ページごとの VM、巨大な結合点は作らない)

マルチページアプリでは、各ページに専用の ViewModel を用意し、アプリ全体の関心事のみを扱うルート/シェル ViewModel(現在のページ、共有状態、VM またはイベントバスへのナビゲーション)を追加します。コンポジションルートですべての VM を構築し、それぞれに独自の依存関係だけを渡して、フェイクを使って単独で構築できるようにします。

root = RootViewModel(settings, api)
root.pageA = PageAVM(settings, api)      # each page gets ONLY its deps
root.pageB = PageBVM(settings, repo)

各ページにはそのページ専用の VM だけを渡してください(root オブジェクト全体は渡しません)。シェルは薄いナビゲーター(StackView/Loader)に保ち、ルート VM の現在のページシグナルにのみ反応させます。次のことは避けてください。

  • すべてのプロパティ/スロットを、巨大な app オブジェクトひとつに置くこと。
  • すべてのページにルート VM 全体を渡すこと。
  • QML にロジックを置くこと。
  • シェルに各ページの内部を把握させること。

リスト/コレクションには QAbstractListModel/QAbstractItemModel を QML に公開してください。QML のビュー(ListView、Repeater)はモデルによって動作するものであり、Python のイテラブルを直接渡すものではありません。


Qt コンポーネントのテスト

pytest-qt を使用します。

def test_main_window_creates(qtbot: QtBot) -> None:
    window = MainWindow()
    qtbot.addWidget(window)
    assert window.isVisible() is False  # Not shown until .show()

def test_button_click(qtbot: QtBot) -> None:
    widget = MyWidget()
    qtbot.addWidget(widget)
    with qtbot.waitSignal(widget.action_triggered, timeout=1000):
        qtbot.mouseClick(widget.button, Qt.LeftButton)

QML ログを Python ロガーにルーティングする

QML の console.log/info/warn/error 呼び出しは、デフォルトでは構造化もログレベルもないまま stderr に出力されます。QML エンジンを作成する前にカスタム Qt メッセージハンドラーをインストールし、Python の logging モジュール経由でルーティングします。

ハンドラー
import logging
from PySide6.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler

_qt_logger = logging.getLogger("qt.qml")

def _qt_message_handler(msg_type: QtMsgType, context: QMessageLogContext, message: str) -> None:
    file: str = context.file or ""
    line: int = context.line or 0
    location = f" ({file}:{line})" if file else ""
    log_message = f"{message}{location}"

    if msg_type == QtMsgType.QtDebugMsg:
        _qt_logger.debug(log_message)
    elif msg_type == QtMsgType.QtInfoMsg:
        _qt_logger.info(log_message)
    elif msg_type == QtMsgType.QtWarningMsg:
        _qt_logger.warning(log_message)
    else:  # QtCriticalMsg, QtFatalMsg
        _qt_logger.error(log_message)
QML エンジンより前にインストールする
qInstallMessageHandler(_qt_message_handler)
engine = QQmlApplicationEngine()

順序が重要です。QQmlApplicationEngine() より前にインストールして、QML の読み込み開始時に発生する警告を捕捉します。

QML の使用方法
Component.onCompleted: {
    console.info("Panel loaded, items: " + listModel.count)
    console.warn("Missing optional property")
    console.error("Failed to load resource")
}
注意点:console.log() は暗黙に破棄されます

Qt は console.log() を QtDebugMsg にマッピングします。Qt 独自のメッセージフィルタリングによって、ハンドラーが呼び出される前に抑制されるため、ハンドラーには届きません。

QML 呼び出しQt の型ハンドラーに届くか推奨事項
console.log()QtDebugMsgいいえ使用しないでください
console.info()QtInfoMsgはいデバッグ出力に使用します
console.warn()QtWarningMsgはい回復可能な問題に使用します
console.error()QtCriticalMsgはいエラーに使用します

console.log() の代わりに、必ず console.info() を使用してください。

ロガー名 qt.qml を使うと、QML メッセージを個別にフィルタリングまたは抑制できます。

logging.getLogger("qt.qml").setLevel(logging.WARNING)  # silence info-level QML noise

このハンドラーで動作する、色付きの標準出力/ファイルロギングの設定については、setting-up-logging スキルを参照してください。


プラットフォーム統合 - ファイルダイアログ(XDG Desktop Portals)

Linux では、ファイルダイアログに XDG Desktop Portals を使用し、ネイティブのシステムピッカー(お気に入り、ブックマークなどを含む)を表示します。プラットフォームテーマが設定されていない場合、アプリは起動時に QT_QPA_PLATFORMTHEME=xdgdesktopportal を設定します。

要件: xdg-desktop-portal とデスクトップバックエンド(xdg-desktop-portal-kde、xdg-desktop-portal-gnome など)。

コードの変更は不要です — プラットフォームテーマが設定されていれば、標準の QFileDialog 呼び出しは自動的にポータルを使用します。Flatpak 環境では、設定なしで透過的にポータルが使用されます。


関連する myai スキル

  • architecting-changes — 親スキル。言語に依存しないアーキテクチャ意思決定フレームワークです。
  • engineering-principles — 親スキル。アーキテクチャの分離や非同期パターンに関する、言語に依存しない原則です。
  • building-multi-ui-apps — Qt GUI がコアを CLI/API と共有する場合の、Python 固有の複数インターフェースのパターンです。
  • setting-up-shortcuts — PySide6 アプリ向けの、Python 固有のキーボードショートカット設定です。
  • setting-up-logging — QML ログのルーティングを含む、Python 固有のロギング設定です。
  • testing-python — pytest-qt を使用した、Python 固有のテストです。
  • writing-python-code — 型システム、エラー処理、非同期パターンなど、Python 固有のコーディング規則です。

コメント