building-qt-apps について
building-qt-appsは、PySide6でQtアプリを開発するPython開発者向けのスキルです。非同期処理、階層化設計、QML、デスクトップ連携、テストの手法を扱い、イベントループをブロックせず、ロジックをテスト可能に保つことを重視します。
主な機能
- 01PySide6とqasyncのパターン
- 02Manager–Service–Wrapperの階層構成
- 03型付きシグナルとスロット
- 04QMLのViewModelアーキテクチャ
- 05pytest-qtによるQtコンポーネントのテスト
活用シーン
- 01Python製Qtアプリの構造設計
- 02イベントループを止めない非同期処理の統合
- 03QMLビューとアプリロジックの分離
- 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)
主なルール
- PyQt ではなく PySide6(LGPL、システム依存関係なし)を使う
- イベントループをブロックしない:
subprocess.run()、time.sleep()、同期 HTTP を使用しない - qasync は asyncio と Qt のイベントループをブリッジする
- ThreadPoolExecutor でブロッキングするサードパーティ API をラップする
- 型付けされていないライブラリは 型付き Wrapper でラップし、ruff の
banned-apiで適用する - シグナルはクラスレベルで定義し、
__init__内には定義しない - 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 固有のコーディング規則です。