برنامه‌نویسی پایتون فهرست مطالب
فصل ۳۶

PySide6 و ساخت فایل اجرایی

با دومین ابزار بزرگ رابط گرافیکی آشنا می‌شویم: همان چیزی که برنامه‌های دسکتاپ حرفه‌ای واقعاً با آن نوشته می‌شوند. پنجره را با ماوس در Qt Designer طراحی می‌کنیم، و در پایان برنامه پایتون را به یک فایل تبدیل می‌کنیم که روی کامپیوتری بدون پایتون هم اجرا شود.

در این فصل می‌خوانید
  1. Qt، و PySide6 یا PyQt6
  2. چهار قدم هر برنامه Qt
  3. سیگنال و اسلات
  4. ویجت‌ها و چیدمان‌ها
  5. دیالوگ‌ها و closeEvent
  6. Qt Designer و فایل ui
  7. ماشین‌حساب اصلاح‌شده
  8. کامپایل کردن فایل ui
  9. PyInstaller: اولین فایل اجرایی
  10. آیکون
  11. دام‌های برنامه منجمد
  12. فایل spec

۱. Qt، و PySide6 یا PyQt6

Qt یک کتابخانه بزرگ ++C برای برنامه‌های دسکتاپ است و دو پل ارتباطی با پایتون دارد. هر دو تقریباً یکسان‌اند و تفاوت اصلی در مجوز است.

PySide6PyQt6
سازندهشرکت Qt (رسمی)Riverbank Computing
مجوزLGPL، رایگان حتی برای برنامه تجاری بستهGPL یا مجوز پولی
وارد کردنfrom PySide6.QtWidgets import ...from PyQt6.QtWidgets import ...
طراحpyside6-designerجداگانه نصب می‌شود
کامپایلر uipyside6-uicpyuic6
سیگنال شخصیSignal و SlotpyqtSignal و pyqtSlot

ما به خاطر مجوز، PySide6 را انتخاب می‌کنیم. هر مثال این فصل با عوض کردن خطوط import به PyQt6 تبدیل می‌شود.

python -m venv .venv
.venv\Scripts\activate          # Windows
source .venv/bin/activate       # Linux / macOS
pip install -r requirements.txt
Qt در برابر tkinter

tkinter جزو خود پایتون است، نصب نمی‌خواهد و برای برنامه‌های کوچک عالی است. Qt باید نصب شود و حجیم است، ولی ویجت‌های بسیار بیشتری دارد، ظاهرش حرفه‌ای‌تر است، طراح گرافیکی دارد، و روی همه سیستم‌عامل‌ها یکسان به نظر می‌رسد. برای یک ابزار کوچک tkinter، و برای یک محصول واقعی Qt.

۲. چهار قدم هر برنامه Qt

import sys

from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget


class HelloWindow(QWidget):
    '''In Qt you make your own window by inheriting from a widget class.'''

    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle('Kadoos - first Qt window')
        self.resize(420, 220)

        title = QLabel('Welcome to Kadoos Institute!')
        # A stylesheet: Qt understands a subset of CSS.
        title.setStyleSheet('font-size: 20px; font-weight: bold;')
        title.setAlignment(Qt.AlignmentFlag.AlignCenter)

        subtitle = QLabel('This window is built with PySide6.')
        subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)

        # A layout places the widgets for you and follows the window when it
        # is resized. Never position widgets by hand with move()/setGeometry().
        layout = QVBoxLayout()
        layout.addWidget(title)
        layout.addWidget(subtitle)
        self.setLayout(layout)


def main() -> None:
    app = QApplication(sys.argv)
    window = HelloWindow()
    window.show()
    sys.exit(app.exec())

اولین پنجره: نمونه 01_hello_window/app.py

شرح مثال

۳. سیگنال و اسلات

در tkinter می‌نوشتیم command=my_function. در Qt هر ویجت سیگنال منتشر می‌کند و شما سیگنال را به یک تابع (که Qt به آن اسلات می‌گوید) وصل می‌کنید. این دقیقاً الگوی ناظر فصل سی و سوم است.

class CounterWindow(QWidget):
    '''A counter and a live greeting.'''

    def __init__(self) -> None:
        super().__init__()
        self.count = 0

        self.count_label = QLabel('0')
        plus = QPushButton('+')
        minus = QPushButton('-')
        reset = QPushButton('Reset')

        # `lambda:` swallows the `checked` argument that clicked sends.
        plus.clicked.connect(lambda: self.change(+1))
        minus.clicked.connect(lambda: self.change(-1))
        reset.clicked.connect(self.reset)

        buttons = QHBoxLayout()
        buttons.addWidget(minus)
        buttons.addWidget(reset)
        buttons.addWidget(plus)

        self.name_edit = QLineEdit()
        self.name_edit.setPlaceholderText('type your name...')
        self.greeting = QLabel('Hello!')
        # textChanged sends the new text to the slot.
        self.name_edit.textChanged.connect(self.on_name_changed)

        layout = QVBoxLayout()
        layout.addWidget(self.count_label)
        layout.addLayout(buttons)          # a layout can hold another layout
        layout.addWidget(self.name_edit)
        layout.addWidget(self.greeting)
        self.setLayout(layout)

    def change(self, step: int) -> None:
        self.count += step
        self.count_label.setText(str(self.count))

    def on_name_changed(self, text: str) -> None:
        name = text.strip()
        self.greeting.setText(f'Hello {name}!' if name else 'Hello!')

سیگنال و اسلات: نمونه 02_signals_and_slots/app.py

سیگنالکی منتشر می‌شودچه می‌فرستد
button.clickedدکمه فشرده شدیک بولی checked
lineEdit.textChangedمتن عوض شدمتن تازه
lineEdit.returnPressedکلید Enterچیزی نمی‌فرستد
slider.valueChangedمقدار عوض شدعدد تازه
comboBox.currentTextChangedانتخاب عوض شدمتن انتخاب‌شده

شرح مثال

۴. ویجت‌ها و چیدمان‌ها

ویجتوظیفه آنخواندن مقدار
QLabelمتن یا تصویر-
QLineEditیک خط متن.text()
QTextEditچند خط متن.toPlainText()
QPushButtonدکمه-
QCheckBoxجعبه تیک.isChecked()
QComboBoxفهرست بازشو.currentText()
QSpinBoxعدد صحیح.value() که واقعاً int است
QListWidgetفهرست.currentItem()
چیدمانوظیفه آن
QVBoxLayoutیکی زیر دیگری
QHBoxLayoutیکی کنار دیگری
QGridLayoutسطر و ستون، مثل grid در tkinter
QFormLayoutجفت «برچسب: فیلد»، ابزار درست فرم‌ها
addStretch()فضای خالی کشسان، برای هل دادن ویجت‌ها
class RegistrationForm(QWidget):
    '''A course registration form built with QFormLayout.'''

    def __init__(self) -> None:
        super().__init__()
        self.name_edit = QLineEdit()
        self.course_box = QComboBox()
        self.course_box.addItems(PRICES)          # the keys of the dict

        self.sessions_box = QSpinBox()
        self.sessions_box.setRange(1, 20)
        self.sessions_box.setValue(20)

        self.student_box = QCheckBox('I am a student (20 % off)')

        form = QFormLayout()
        form.addRow('Full name:', self.name_edit)
        form.addRow('Course:', self.course_box)
        form.addRow('Sessions:', self.sessions_box)
        form.addRow('', self.student_box)

        buttons = QHBoxLayout()
        buttons.addStretch()               # push the buttons to the right
        buttons.addWidget(clear)
        buttons.addWidget(compute)

    def compute(self) -> None:
        name = self.name_edit.text().strip()
        if not name:
            self.result.setText('Please type your name first.')
            return

        course = self.course_box.currentText()
        sessions = self.sessions_box.value()          # already an int
        price = PRICES[course] * sessions // 20
        if self.student_box.isChecked():
            price = int(price * 0.8)

        self.result.setText(f'{name}: {course}, {sessions} sessions '
                            f'-> {price:,} toman')

فرم ثبت‌نام: نمونه 03_widgets_and_layouts/app.py

تفاوت ریز ولی مهمی را ببینید: QSpinBox.value() یک int واقعی می‌دهد، برخلاف Spinbox.get() در tkinter که رشته می‌داد. خود ویجت هم هر چیزی جز عدد را نمی‌پذیرد. Qt در این موارد خیلی کارتان را راحت‌تر می‌کند.

۵. دیالوگ‌ها و closeEvent

    def ask_question(self) -> None:
        answer = QMessageBox.question(self, 'Kadoos', 'Do you like Python?')
        # Always compare against the StandardButton values.
        liked = answer == QMessageBox.StandardButton.Yes
        self.answer_label.setText('You answered: ' + ('yes' if liked else 'no'))

    def open_file(self) -> None:
        # getOpenFileName returns (path, filter) -- never forget the tuple.
        path, _filter = QFileDialog.getOpenFileName(
            self, 'Open', '', 'Text files (*.txt);;All files (*)')
        if not path:                       # Cancel
            return
        self.answer_label.setText(f'You chose: {path}')

    def closeEvent(self, event) -> None:
        '''Qt calls this when the user clicks the X button.'''
        answer = QMessageBox.question(self, 'Exit', 'Really close the program?')
        if answer == QMessageBox.StandardButton.Yes:
            event.accept()                 # let the window close
        else:
            event.ignore()                 # keep it open

دیالوگ‌ها: نمونه 04_message_boxes/app.py

شرح مثال

۶. Qt Designer و فایل ui

Qt Designer برنامه‌ای است که در آن ویجت‌ها را با ماوس روی پنجره می‌کشید و نتیجه را در یک فایل .ui ذخیره می‌کنید. آن فایل XML ساده است و می‌توانید در ویرایشگر متن بازش کنید و بخوانید. این همان XML فصل بیست و هشتم است.

pyside6-designer
def load_ui(name: str) -> QWidget:
    '''Load a .ui file that sits next to this script.'''
    # `with_name` keeps the folder of THIS file, so the program works no
    # matter which directory you run it from.
    path = Path(__file__).with_name(name)
    window = QUiLoader().load(str(path))
    if window is None:
        # A typo in the file name, or invalid XML.
        raise RuntimeError(f'Cannot load {path}')
    return window


def main() -> None:
    app = QApplication(sys.argv)
    window = load_ui('window.ui')

    # findChild(type, objectName) -> the widget, or None if the name is wrong.
    title = window.findChild(QLabel, 'labelTitle')
    button = window.findChild(QPushButton, 'btnClose')
    if title is None or button is None:
        raise RuntimeError('An objectName does not match window.ui')

    title.setText('Welcome to Kadoos Institute!')
    button.clicked.connect(window.close)

    window.show()
    sys.exit(app.exec())

بارگذاری در زمان اجرا: نمونه 05_designer_first_ui/app.py

دو قانون طراحی در Designer

findChild ممکن است None بدهد

اگر نام را اشتباه بنویسید، findChild بدون هیچ خطایی None برمی‌گرداند و برنامه خیلی بعدتر و دور از محل اشتباه می‌ترکد. همیشه بلافاصله بررسی کنید. نسخه اصلی این درس دقیقاً همین بررسی را نداشت.

۷. ماشین‌حساب اصلاح‌شده

ماشین‌حساب اصلی این درس چهار مشکل داشت که هر چهار تا ارزش دیدن دارند: چهار تابع هشت‌خطی که فقط در یک کاراکتر فرق داشتند، float(edit.text()) بدون هیچ محافظی، تقسیم بر صفر، و متغیری به نام sum که تابع درونی پایتون را پنهان می‌کرد.

import operator
from typing import Callable

# The four operations, as data instead of as four copies of the same function.
OPERATIONS: dict[str, Callable[[float, float], float]] = {
    'btnAdd': operator.add,
    'btnSub': operator.sub,
    'btnMul': operator.mul,
    'btnDiv': operator.truediv,
}


class Calculator:
    '''A two-number calculator driven by calc.ui.'''

    def __init__(self) -> None:
        path = Path(__file__).with_name('calc.ui')
        self.window: QWidget = QUiLoader().load(str(path))
        ...
        for name, function in OPERATIONS.items():
            button = self._find(QPushButton, name)
            # `f=function` freezes the current value; without it every button
            # would use the LAST operation of the loop.
            button.clicked.connect(lambda checked=False, f=function:
                                   self.calculate(f))

    def read_numbers(self) -> tuple[float, float] | None:
        '''Read both fields, or show why they cannot be read.'''
        try:
            first = float(self.edit1.text().strip())
            second = float(self.edit2.text().strip())
        except ValueError:
            # Empty field, a letter, two dots... one message for all of them.
            self.show_error('Please type two numbers.')
            return None
        return first, second

    def calculate(self, function: Callable[[float, float], float]) -> None:
        numbers = self.read_numbers()
        if numbers is None:
            return
        try:
            answer = function(*numbers)
        except ZeroDivisionError:
            self.show_error('Cannot divide by zero.')
            return
        self.result.setText(f'{answer:.2f}')

ماشین‌حساب: نمونه 07_designer_calculator/app.py

شرح مثال

۸. کامپایل کردن فایل ui

راه دوم استفاده از فایل .ui این است که یک بار آن را به کلاس پایتون کامپایل کنیم.

pyside6-uic window.ui -o ui_window.py       # PyQt6: pyuic6
from PySide6.QtWidgets import QApplication, QWidget

from ui_window import Ui_Form


class TodoWindow(QWidget, Ui_Form):
    '''A to-do list. The widgets come from the generated Ui_Form.'''

    def __init__(self) -> None:
        super().__init__()
        self.setupUi(self)          # builds every widget on `self`

        self.btnAdd.clicked.connect(self.add_task)
        self.editTask.returnPressed.connect(self.add_task)
        self.btnRemove.clicked.connect(self.remove_task)
        self.btnClear.clicked.connect(self.clear_tasks)

        self.update_count()

    def add_task(self) -> None:
        text = self.editTask.text().strip()
        if not text:
            return
        self.listTasks.addItem(text)
        self.editTask.clear()
        self.update_count()

    def remove_task(self) -> None:
        row = self.listTasks.currentRow()    # -1 when nothing is selected
        if row < 0:
            return
        # takeItem removes the row and gives the item back; we drop it.
        self.listTasks.takeItem(row)
        self.update_count()

نسخه کامپایل‌شده: نمونه 08_compile_ui_with_uic/app.py

QUiLoader در زمان اجراpyside6-uic کامپایل‌شده
بعد از تغییر طرح، کاری ضرورتی نداردبعد از هر تغییر باید دوباره uic بزنید
ویجت با نام و در زمان اجرا پیدا می‌شودویجت‌ها صفت واقعی شیء هستند
تکمیل خودکار ندارد، غلط املایی در زمان اجرا معلوم می‌شودویرایشگر کامل می‌کند و اشتباه را می‌گیرد
فایل ui باید همراه برنامه ارسال شودفقط فایل‌های py، پس بسته‌بندی ساده‌تر است
هرگز فایل تولیدشده را دستی ویرایش نکنید

اجرای بعدی pyside6-uic فایل ui_window.py را کامل بازنویسی می‌کند و همه تغییرهای دستی‌تان از بین می‌رود. کد خودتان در فایل جداگانه می‌ماند. البته یک بار داخل فایل تولیدشده را بخوانید: هیچ جادویی در کار نیست، دقیقاً همان کدی است که در بخش‌های اول این فصل دستی نوشتیم.

۹. PyInstaller: اولین فایل اجرایی

PyInstaller پایتون را به کد ماشین کامپایل نمی‌کند. بلکه سه چیز را کنار هم بسته‌بندی می‌کند: فایل‌های شما، خود مفسر پایتون، و همه کتابخانه‌هایی که وارد کرده‌اید. نتیجه روی کامپیوتری که اصلاً پایتون ندارد اجرا می‌شود، و این تمام هدف ماجراست.

pip install pyinstaller
pyinstaller --onefile app.py
مسیرچیست
dist/app.exeفایلی که به دیگران می‌دهید
build/فایل‌های موقت، قابل حذف
app.specدستور پخت ساخت
گزینهوظیفه آن
--onefileیک فایل واحد
--onedir (پیش‌فرض)یک پوشه شامل exe و کلی dll
--name kadoosنام خروجی
--icon=app.icoآیکون فایل
--windowedبدون پنجره سیاه کنسول
--add-dataهمراه بردن یک فایل داده
--cleanدور ریختن حافظه نهان پیش از ساخت

نکته‌های عملی

۱۰. آیکون

pyinstaller --onefile --icon=app.ico app.py

آیکون ویندوز نه PNG است و نه JPEG. یک فایل .ico ظرفی است که یک تصویر را در چند اندازه نگه می‌دارد، تا ویندوز برای میزکار و نوار وظیفه و فهرست فایل‌ها اندازه مناسب را بردارد.

from PIL import Image

image = Image.open('logo.png')
image.save('app.ico', sizes=[(16, 16), (32, 32), (48, 48), (256, 256)])

ساخت ico با Pillow، از فصل بیست و هشتم

دو نکته که همه را گیج می‌کند

۱۱. دام‌های برنامه منجمد

pyinstaller --onefile --windowed --icon=app.ico --add-data "app.ico;." app.py

جداکننده در --add-data روی ویندوز نقطه‌ویرگول و روی لینوکس و مک دونقطه است. این تفاوت کوچک، ساعت‌ها وقت مردم را گرفته است.

وقتی یک برنامه --onefile اجرا می‌شود، خودش را در یک پوشه موقت باز می‌کند و بعد از پایان پاکش می‌کند. پایتون آن وقت این‌ها را می‌بیند:

چیزمقدارش در برنامه منجمد
sys.frozenTrue؛ این صفت در حالت عادی اصلاً وجود ندارد
sys._MEIPASSپوشه موقت
__file__مسیری داخل همان پوشه موقت
sys.executableخود فایل exe، که جایش دائمی است
'''The rule in one line:
    resource_path() for what you READ and ship,
    data_path() for what you WRITE.
'''

import sys
from pathlib import Path


def is_frozen() -> bool:
    '''True when we are running inside a PyInstaller executable.'''
    return getattr(sys, 'frozen', False)


def resource_path(name: str) -> Path:
    '''Where to READ a file that was shipped with the program.'''
    if is_frozen():
        # PyInstaller unpacks --add-data files into sys._MEIPASS.
        base = Path(sys._MEIPASS)          # type: ignore[attr-defined]
    else:
        base = Path(__file__).parent
    return base / name


def data_path(name: str) -> Path:
    '''Where to WRITE a file so that it survives the next run.'''
    if is_frozen():
        # sys.executable is the .exe itself; its folder is permanent.
        base = Path(sys.executable).parent
    else:
        base = Path(__file__).parent
    return base / name

دو مسیر لازم هر برنامه منجمد: نمونه 11_pyinstaller_windowed/paths.py

چهار دام

۱۲. فایل spec

تایپ کردن دوباره و دوباره یک خط فرمان بلند، خسته‌کننده است. PyInstaller یک دستور پخت می‌نویسد: فایل .spec، که خودش کد پایتون ساده است و می‌توانید بخوانید و ویرایشش کنید.

# 1. write the recipe (without building anything)
pyi-makespec --onefile --windowed --icon=app.ico app.py

# 2. build from the recipe -- notice: the .spec, not the .py
pyinstaller app.spec
a = Analysis(
    ['app.py'],                 # the entry point
    pathex=[],
    binaries=[],
    datas=[('words.txt', '.')], # <- the data files: (source, destination)
    hiddenimports=[],           # <- imports PyInstaller could not find
    excludes=['tkinter'],       # <- modules to leave out, to shrink the build
    ...
)

exe = EXE(
    ...
    name='kadoos',              # the name of the result
    console=False,              # False = --windowed
    icon='app.ico',
)

درون فایل spec: نمونه 12_pyinstaller_spec

خطبرای چه
datasفرستادن تصویر، فایل ui، قالب پایگاه داده
hiddenimportsماژولی که فقط از راه importlib وارد می‌شود
excludesحذف چیزهایی که به کار نمی‌آیند، برای کوچک شدن خروجی
دو نکته درباره spec

وقتی از روی .spec می‌سازید، گزینه‌های خط فرمان مثل --onefile نادیده گرفته می‌شوند؛ دستور پخت تصمیم می‌گیرد. فایل را ویرایش کنید، نه فرمان را. دوم اینکه در datas از تاپل استفاده می‌شود و آن ماجرای نقطه‌ویرگول و دونقطه فقط مال خط فرمان است.

نکته آخر درباره وارد کردن‌های پنهان: PyInstaller وارد کردن‌ها را با خواندن کد پیدا می‌کند. پس importlib.import_module(name) برایش نامرئی است و برنامه فقط داخل exe با ModuleNotFoundError شکست می‌خورد. راه حل --hidden-import یا فهرست hiddenimports است.

فایل‌های تمرینی این فصل

پوشه S36-pyside6-and-packaging شامل دوازده پوشه است. نمونه‌های ۰۹ تا ۱۲ هر کدام README.md جداگانه‌ای با فرمان‌های دقیق دارند. پیش از شروع، محیط مجازی بسازید و pip install -r requirements.txt بزنید. Qt Designer را با pyside6-designer باز کنید.

خلاصه فصل

ابزاروظیفه آن
QApplication(sys.argv)یکی در هر برنامه
window.show()ویجت‌ها تا صدا نزنید پنهان‌اند
app.exec()حلقه رویداد Qt
signal.connect(slot)اتصال رویداد به تابع، بدون پرانتز
QVBoxLayout و خانواده‌اشچیدمان. هرگز مختصات پیکسلی ندهید
setStyleSheetزیرمجموعه‌ای از CSS
QMessageBox.questionدکمه فشرده‌شده را برمی‌گرداند
QFileDialog.getOpenFileNameیک تاپل برمی‌گرداند
closeEventگرفتن دکمه بستن پنجره
QUiLoader().load(path)بارگذاری ui در زمان اجرا
pyside6-uicکامپایل ui به کلاس پایتون
findChild(type, name)ممکن است None بدهد. بررسی کنید
pyinstaller --onefileساخت یک فایل اجرایی
--windowedحذف کنسول، برای هر برنامه گرافیکی
--add-dataهمراه بردن فایل داده
sys.frozen و sys._MEIPASSتشخیص و مکان‌یابی در برنامه منجمد
resource_path و data_pathیکی برای خواندن، یکی برای نوشتن

تمرین‌های فصل سی و شش

  1. مبدل دمای فصل سی و چهارم را با PySide6 بازنویسی کنید.
  2. یک QSlider به نمونه دوم اضافه کنید و valueChanged آن را هم به یک برچسب و هم به رنگ پس‌زمینه پنجره وصل کنید: یک سیگنال، دو اسلات.
  3. به فرم ثبت‌نام، دو دکمه رادیویی داخل یک QButtonGroup اضافه کنید و انتخاب را در نتیجه بیاورید.
  4. فرم رمز عبور بسازید: دو QLineEdit با حالت رمز، و برچسبی که زنده بگوید آیا دو رمز یکی هستند.
  5. با QMainWindow و QAction به یکی از نمونه‌ها نوار منو اضافه کنید.
  6. پنجره ورود را در Designer بکشید و با QUiLoader به وظیفه آن بیندازید.
  7. همان فایل ui را با pyside6-uic کامپایل کنید و به روش دوم راهش بیندازید. کدام را ترجیح دادید و چرا؟
  8. به ماشین‌حساب، دکمه درصد و دکمه جذر اضافه کنید، بدون نوشتن متد تازه برای هر کدام.
  9. ماشین‌حساب را به یک فایل اجرایی با آیکون و بدون کنسول تبدیل کنید. مواظب باشید فایل ui هم باید همراهش برود.
  10. برنامه مدیریت کتابخانه فصل سی و پنجم را به exe تبدیل کنید و مطمئن شوید library.db کنار فایل اجرایی نوشته می‌شود و داده‌ها بعد از بستن برنامه باقی می‌مانند.
  11. عمداً یک ساخت را خراب کنید: --add-data را بردارید، exe را اجرا کنید و پیام را بخوانید. بعد یک فایل گزارش اضافه کنید که همین خرابی را حتی در حالت پنجره‌ای ثبت کند.
  12. حجم و زمان راه‌اندازی --onefile و --onedir را برای یک برنامه مقایسه کنید.
  13. چرا PyInstaller نمی‌تواند روی لینوکس، فایل اجرایی ویندوز بسازد؟
  14. چرا برنامه‌ای که پایگاه داده‌اش را کنار __file__ می‌نویسد در حالت توسعه درست است و بعد از منجمد شدن غلط؟
فصل قبل: tkinter پیشرفته فصل بعد: همروندی، نخ‌ها و asyncio