22. (Л) Обробка помилок. Налаштування застосунку¶
Зміст лекції¶
- Проблема: застосунок, який ламається мовчки
- Три роди проблем: баг, збій, некоректне введення
- Винятки: короткий повтор під кутом GUI
- Що Qt робить із винятком у слоті
- Власні винятки й межі шарів
- Де ловити: правило найближчого шару, який може щось зробити
- Останній рубіж:
sys.excepthook - Логування замість
print - Куди писати лог:
QStandardPathsі ротація - Повідомлення самого Qt у тому ж лозі
- Як показати помилку користувачу
- Текст повідомлення: що писати, а чого не писати
- Не ловіть того, чого можна не допустити: валідація
- Помилки у фоновому потоці
- Надійне збереження: атомарний запис і резервна копія
- Налаштування: що це і чим воно не є
QSettings: перше знайомство- Типи значень і головна пастка
value() - Групи та масиви
- Геометрія вікна й стан
QMainWindow - Свій файл налаштувань: коли JSON кращий за
QSettings - Шар налаштувань в архітектурі
- Діалог налаштувань і застосування «наживо»
- Збірка: «Notes» із логом, обробкою помилок і налаштуваннями
- Типові помилки
- Підсумок
Проблема: застосунок, який ламається мовчки¶
Усі приклади попередніх лекцій мали спільну зручну властивість: у них нічого не ламалося. Файл завжди існував, JSON завжди був коректний, диск завжди мав місце, а користувач завжди вводив число там, де очікувалося число.
Реальність інша. Ось типовий застосунок, який читає дані з файла:
import json
import sys
from pathlib import Path
from PySide6.QtWidgets import (
QApplication,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
class Window(QWidget):
"""Завантаження даних без жодної обробки помилок."""
def __init__(self):
super().__init__()
self.setWindowTitle("Fragile loader")
self.status = QLabel("No data")
load_button = QPushButton("Load notes.json")
load_button.clicked.connect(self.load_notes)
layout = QVBoxLayout(self)
layout.addWidget(self.status)
layout.addWidget(load_button)
def load_notes(self):
text = Path("notes.json").read_text(encoding="utf-8")
notes = json.loads(text)
self.status.setText(f"Loaded {len(notes)} notes")
def main():
app = QApplication(sys.argv)
window = Window()
window.resize(320, 120)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Запустіть його в порожньому каталозі й натисніть кнопку. У терміналі з'явиться:
Traceback (most recent call last):
File "fragile.py", line 34, in load_notes
text = Path("notes.json").read_text(encoding="utf-8")
...
FileNotFoundError: [Errno 2] No such file or directory: 'notes.json'
А тепер найважливіше: вікно не закрилося. Воно й далі показує No data, кнопка й далі натискається, застосунок працює. Користувач бачить рівно нічого — жодного повідомлення, жодної підказки. Він натискає кнопку вдруге, втретє, потім вирішує, що програма зламана, і закриває її.
Термінал бачите ви. Користувач бачить порожнє вікно.
Спробуйте ще три сценарії з тим самим файлом:
| Що зробити | Що станеться |
|---|---|
створити порожній notes.json |
json.decoder.JSONDecodeError: Expecting value |
написати у файл {"a": 1} замість списку |
помилки немає, len() поверне 1 — тихо неправильний результат |
зняти права на читання (chmod 000 notes.json) |
PermissionError: [Errno 13] Permission denied |
Третій рядок гірший за перші два: там, де немає винятку, немає й сигналу про проблему. Застосунок упевнено показує неправду.
Ця лекція — про те, як зробити, щоб:
- жодна помилка не зникала непоміченою;
- користувач отримував зрозуміле повідомлення, а не порожнечу;
- ви — розробник — отримували повний технічний слід у файлі лога;
- застосунок після помилки лишався в робочому стані, а не в напівзруйнованому.
Друга половина лекції — про налаштування: де застосунок зберігає розмір вікна, обраний шлях до файла, тему й останні відкриті документи, і чому це окремий шар, а не пара глобальних змінних.
Три роди проблем: баг, збій, некоректне введення¶
Слово «помилка» означає три різні речі, і поводитись із ними треба по-різному.
1. Баг — помилка програміста. AttributeError, TypeError, IndexError, NameError, звернення до None. Це наслідок неправильного коду. Такі помилки не ловлять точково: ловити AttributeError навколо кожного виклику — означає ховати власні дефекти. Їх ловить лише глобальний перехоплювач, щоб записати в лог і не дати застосунку загинути тихо.
2. Очікуваний збій — помилка середовища. Файла немає, диск заповнений, немає прав, мережа недоступна, база даних не відповідає, файл пошкоджений. Це нормальна частина роботи застосунку: така помилка станеться колись обов'язково, і код зобов'язаний її передбачити. Її ловлять точково, у конкретному місці, і перетворюють на зрозумілу дію.
3. Некоректне введення користувача. Порожня назва, вік -5, дата в майбутньому, ціна з літерами. Це взагалі не помилка в сенсі винятків. Це звичайний стан форми, який треба перевірити до дії й показати підказку біля поля.
graph TD
E["Щось пішло не так"] --> B{"Хто винен?"}
B -->|"код"| BUG["Баг<br/>лог + глобальний перехоплювач<br/>виправляє програміст"]
B -->|"середовище"| FAIL["Збій<br/>try/except у місці виклику<br/>повідомлення + план Б"]
B -->|"користувач"| INPUT["Введення<br/>перевірка до дії<br/>підказка біля поля"]
Плутанина між цими трьома — джерело двох найпоширеніших крайнощів:
- немає жодного
try— застосунок валиться на будь-якому відсутньому файлі; try: ... except Exception: passнавколо всього — застосунок не валиться ніколи, але й не працює, і знайти причину неможливо.
Правильна відповідь посередині, і решта лекції — про те, де саме ця середина.
Винятки: короткий повтор під кутом GUI¶
Механіку try / except ви знаєте з першого курсу. Пригадаємо чотири речі, які в GUI-застосунку важать найбільше.
Повна форма:
try:
data = load_file(path) # може кинути виняток
except FileNotFoundError:
data = [] # план Б для конкретного випадку
except OSError as error:
show_error(str(error)) # ширший клас: права, диск, шлях
return
else:
logger.info("Loaded %s", path) # виконується, якщо винятку не було
finally:
self.set_busy(False) # виконується завжди
finally — це про відновлення інтерфейсу. Якщо перед довгою операцією ви вимкнули кнопку й поставили курсор «очікування», увімкнути назад треба обов'язково — і при успіху, і при помилці. Інакше після першої ж помилки кнопка лишиться назавжди вимкненою.
Порядок except має значення. Гілки перевіряються згори вниз, спрацьовує перша підхожа. FileNotFoundError — підклас OSError, тому вужчий клас пишуть вище. Якщо поставити except OSError першим, гілка FileNotFoundError не виконається ніколи.
Корисна частина ієрархії:
BaseException
└── Exception
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ ├── IsADirectoryError
│ └── FileExistsError
├── ValueError
│ └── json.JSONDecodeError (також підклас ValueError)
├── KeyError
├── TypeError
└── AttributeError
raise ... from зберігає причину. Коли ви перетворюєте низькорівневий виняток на свій, не втрачайте оригінал:
try:
raw = json.loads(text)
except json.JSONDecodeError as error:
raise StorageError(f"Broken file: {path}") from error
У лозі буде обидва traceback і рядок The above exception was the direct cause of the following exception. Без from ви побачите лише свій виняток і не дізнаєтесь, у якому символі файла проблема.
except Exception, а не except. Голий except: ловить також KeyboardInterrupt і SystemExit — тобто заважає застосунку завершитись. Ловіть Exception, і лише там, де це справді останній рубіж.
Ніколи не пишіть except Exception: pass
Це найдорожчий рядок у програмуванні. Він не усуває проблему, а робить її невидимою — і ви шукатимете її годинами замість секунд. Якщо виняток справді можна проігнорувати, напишіть чому:
Що Qt робить із винятком у слоті¶
Це найважливіший практичний факт цієї лекції, і його варто перевірити руками.
Слот викликається не з вашого коду, а з циклу подій — тобто з боку C++. Коли всередині слота виникає виняток, він не має куди «спливти»: у стеку вище немає жодного Python-try. Ось приклад, який це показує:
import sys
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QVBoxLayout, QWidget
class Window(QWidget):
"""Слот кидає виняток. Дивимось, що станеться із застосунком."""
def __init__(self):
super().__init__()
self.setWindowTitle("Exception in slot")
self.counter = 0
self.label = QLabel("Clicks that survived: 0")
boom_button = QPushButton("Raise exception")
boom_button.clicked.connect(self.boom)
ok_button = QPushButton("I still work")
ok_button.clicked.connect(self.tick)
layout = QVBoxLayout(self)
layout.addWidget(self.label)
layout.addWidget(boom_button)
layout.addWidget(ok_button)
def boom(self):
self.label.setText("Before the exception")
raise ValueError("something went wrong")
self.label.setText("After the exception") # ніколи не виконається
def tick(self):
self.counter += 1
self.label.setText(f"Clicks that survived: {self.counter}")
def main():
app = QApplication(sys.argv)
window = Window()
window.resize(320, 160)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Натисніть Raise exception, а потім I still work. Спостереження:
- У термінал друкується traceback.
- Вікно не закривається, цикл подій живий, друга кнопка працює.
- Напис лишився
Before the exception— тобто слот обірвався посередині.
Пункт 3 і є справжня небезпека. Виняток у слоті не валить застосунок — він лишає його в напівзробленому стані. Уявіть слот, який:
def rename_note(self, note_id, new_title):
self.set_busy(True) # 1. кнопки вимкнено
note = self._notes[note_id] # 2. KeyError, якщо нотатки немає
note.title = new_title
self._storage.save(self._notes)
self.set_busy(False) # 3. сюди вже не дійшли
Після KeyError кнопки лишаються вимкненими назавжди, і застосунок для користувача мертвий — хоча формально «працює».
Те саме стосується перевизначених віртуальних методів. Виняток у data() вашої моделі PySide6 покаже окремо:
Error calling Python override of QAbstractTableModel::data(): Traceback (most recent call last):
...
KeyError: 'bad column'
Комірка просто намалюється порожньою — представлення продовжить питати інші комірки, і в лог посиплються сотні однакових traceback.
Висновки:
- Виняток у слоті не завершує застосунок — він тихо ламає одну дію.
- Traceback іде в
stderr, якого користувач ніколи не побачить. Запакований застосунок (лекція 24) часто взагалі не має консолі — і тоді traceback зникає безслідно. - Отже, потрібні дві речі: точкова обробка там, де збій очікуваний, і глобальний перехоплювач, який ловить решту й записує її в лог.
Власні винятки й межі шарів¶
З лекції 14 відомо про три шари: дані → стан → інтерфейс. Винятки цю межу теж поважають.
Уявіть, що main_window.py пише так:
Вікно тепер знає, що дані лежать у JSON. Замініть сховище на SQLite — і цей except перестане ловити будь-що, мовчки. Вікно не має права знати про формат.
Розв'язок — власний тип винятку на межі шару:
Шар збереження ловить усе своє й перекладає:
import json
from pathlib import Path
class StorageError(Exception):
"""Помилка шару збереження, зрозуміла верхнім шарам."""
class JsonStorage:
def __init__(self, path):
self._path = Path(path)
def load(self):
if not self._path.exists():
return [] # порожньо — не помилка
try:
text = self._path.read_text(encoding="utf-8")
raw = json.loads(text)
except OSError as error:
raise StorageError(f"Cannot read {self._path}: {error}") from error
except json.JSONDecodeError as error:
raise StorageError(
f"File {self._path} is damaged (line {error.lineno})"
) from error
if not isinstance(raw, list):
raise StorageError(f"Unexpected format in {self._path}: list expected")
return raw
Зверніть увагу на три рішення в цьому коді:
- Відсутній файл — не помилка. Перший запуск застосунку нормальний: даних ще немає, повертаємо порожній список. Помилкою є файл, який є, але прочитати його неможливо.
- Пошкоджений формат теж перевіряється.
json.loadsвіддасть словник або число без жодного винятку — томуisinstanceобов'язковий. Це саме той «тихо неправильний результат» із першого розділу. from errorзберігає причину для лога.
Тепер верхні шари ловлять один тип:
try:
notes = storage.load()
except StorageError as error:
self.error_occurred.emit(str(error))
notes = []
І цей код лишиться правильним, коли всередині JsonStorage з'явиться SQLite, мережа або зашифрований файл.
Скільки власних винятків потрібно
Один на шар — гарний старт: StorageError, NetworkError, ValidationError. Заводьте підкласи (FileCorruptedError(StorageError)) лише тоді, коли верхній шар справді по-різному реагує на різні випадки. Виняток, який ніхто не ловить окремо, не потрібен.
Де ловити: правило найближчого шару, який може щось зробити¶
Найчастіше питання — «а де саме писати try?». Правило одне:
Виняток ловлять там, де є що з ним зробити.
«Зробити» означає одне з трьох: підставити план Б, повідомити користувача, перекласти у виняток свого шару. Якщо в цьому місці жодне з трьох неможливо — не ловіть, хай летить вище.
Розберемо на прикладі кнопки «Export» у застосунку нотаток.
Шар збереження знає про файл, але не знає, хто його просив і як показати помилку. Він перекладає:
def export(self, path, notes):
try:
Path(path).write_text(json.dumps(notes), encoding="utf-8")
except OSError as error:
raise StorageError(f"Cannot write {path}: {error}") from error
Шар стану знає, що операція не вдалася, і має сигнали. Він перетворює виняток на сигнал:
def export_to(self, path):
try:
self._storage.export(path, self._notes)
except StorageError as error:
self.error_occurred.emit(str(error))
return False
return True
Шар інтерфейсу знає користувача. Він показує:
def on_export(self):
path, _ = QFileDialog.getSaveFileName(self, "Export notes", "", "JSON (*.json)")
if not path:
return # користувач скасував — не помилка
if self._state.export_to(path):
self.statusBar().showMessage(f"Exported to {path}", 5000)
Повідомлення про помилку покаже слот, під'єднаний до error_occurred — один на все вікно.
graph LR
UI["main_window.py<br/>показує QMessageBox"] -->|"виклик"| ST["state.py<br/>ловить StorageError<br/>→ error_occurred"]
ST -->|"виклик"| SG["storage.py<br/>ловить OSError<br/>→ StorageError"]
SG -->|"виклик"| OS["open / write_text"]
OS -.->|"OSError"| SG
SG -.->|"StorageError"| ST
ST -.->|"сигнал"| UI
Два антипатерни, яких це правило позбавляє:
Ловити надто рано. except усередині циклу по файлах, який просто робить continue, — і користувач ніколи не дізнається, що половина файлів не скопіювалась.
Ловити надто пізно. Один величезний try навколо всієї main(). Формально помилка спіймана, практично — незрозуміло, яка саме дія не вдалася, і застосунок уже закривається.
Останній рубіж: sys.excepthook¶
Точкова обробка закриває очікувані збої. Баги лишаються — і саме вони найнебезпечніші, бо приходять несподівано.
Python має глобальний перехоплювач sys.excepthook: функцію, яку викликають для будь-якого винятку, що дійшов до верху й ніким не спійманий. PySide6 користується ним і для винятків зі слотів — це легко перевірити.
import sys
import traceback
from PySide6.QtWidgets import (
QApplication,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
def install_excepthook(app):
"""Показує вікно з помилкою замість тихого traceback у консолі."""
def hook(exc_type, exc_value, exc_tb):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_tb) # Ctrl+C — не наша справа
return
details = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
print(details, file=sys.stderr) # далі замінимо на лог
box = QMessageBox()
box.setIcon(QMessageBox.Critical)
box.setWindowTitle("Unexpected error")
box.setText("The application ran into an unexpected problem.")
box.setInformativeText(f"{exc_type.__name__}: {exc_value}")
box.setDetailedText(details)
box.setStandardButtons(QMessageBox.Close)
box.exec()
sys.excepthook = hook
class Window(QWidget):
"""Дві кнопки: одна ламається, друга доводить, що застосунок живий."""
def __init__(self):
super().__init__()
self.setWindowTitle("Global excepthook")
self.counter = 0
self.label = QLabel("Clicks: 0")
boom_button = QPushButton("Break something")
boom_button.clicked.connect(self.boom)
ok_button = QPushButton("Count a click")
ok_button.clicked.connect(self.tick)
layout = QVBoxLayout(self)
layout.addWidget(self.label)
layout.addWidget(boom_button)
layout.addWidget(ok_button)
def boom(self):
notes = {"a": 1}
return notes["missing key"] # KeyError
def tick(self):
self.counter += 1
self.label.setText(f"Clicks: {self.counter}")
def main():
app = QApplication(sys.argv)
install_excepthook(app)
window = Window()
window.resize(340, 160)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Натисніть Break something — з'явиться вікно з коротким текстом і кнопкою Show Details..., яка розгортає повний traceback. Натисніть Count a click — застосунок далі працює.
Три деталі реалізації:
traceback.format_exceptionповертає список рядків — саме те, що треба покласти вsetDetailedText(). Не намагайтесь зібрати текст вручну черезstr(exc_value): там не буде ні місця, ні стека.KeyboardInterruptпропускають далі черезsys.__excepthook__— оригінальний хук, який Python зберігає завжди. ІнакшеCtrl+Cу терміналі відкриватиме діалог замість завершення програми.- Хук ставлять після створення
QApplication. До тогоQMessageBoxстворити не можна.
Обережно з вікном у хуку
Якщо виняток стався всередині малювання, а хук відкриє модальне вікно, ви ризикуєте отримати нескінченну низку діалогів: діалог викликає перемальовування, воно знову падає, знову діалог. У серйозному застосунку хук ставить прапорець «діалог уже показано» й не відкриває другий, поки перший не закрито. Мінімальний варіант — лічильник:
Чого sys.excepthook не ловить. Він працює для головного потоку. Виняток у run() фонового воркера туди не потрапить — про це буде окремий розділ. Не ловить він і падіння на рівні C++ (segmentation fault): від цього рятує хіба що faulthandler.enable(), який друкує стек Python-коду перед аварійним завершенням процесу.
Логування замість print¶
print() у GUI-застосунку майже безкорисний: користувач консолі не бачить, а запакований застосунок її часто не має взагалі. Потрібен лог у файл.
Стандартний модуль logging дає це в кілька рядків.
import logging
import sys
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
handlers=[
logging.FileHandler("app.log", encoding="utf-8"),
logging.StreamHandler(sys.stderr),
],
)
logger = logging.getLogger(__name__)
logger.info("Application started")
Результат у файлі:
П'ять рівнів і коли їх брати:
| Рівень | Коли |
|---|---|
DEBUG |
подробиці для розробника: значення змінних, хід алгоритму |
INFO |
звичайні події застосунку: запуск, відкриття файла, збереження |
WARNING |
щось дивне, але працюємо далі: файл налаштувань пошкоджений, узяли типові |
ERROR |
операція не вдалася: не змогли зберегти, не змогли завантажити |
CRITICAL |
застосунок далі працювати не може |
Логер на модуль. У кожному файлі пишуть один рядок:
__name__ дає app.storage, app.state, app.ui.main_window. У лозі одразу видно, звідки рядок, і можна вимкнути окремий модуль:
Аргументи передають через %s, а не через f-рядок:
logger.debug("Loaded %s notes from %s", len(notes), path) # правильно
logger.debug(f"Loaded {len(notes)} notes from {path}") # зайва робота
У першому варіанті форматування станеться лише якщо рівень DEBUG увімкнений. У другому — рядок збереться завжди, навіть коли його ніхто не запише.
exc_info=True і logger.exception. Найважливіше в лозі — traceback. Два способи його записати:
try:
self._storage.save(self._notes)
except StorageError:
logger.exception("Save failed") # усередині except: рівень ERROR + traceback
logger.exception() можна викликати тільки всередині except — він бере поточний виняток.
Порівняйте, що потрапить у файл. Без exc_info:
З exc_info:
2026-09-08 10:15:41,882 ERROR app.state: Save failed
Traceback (most recent call last):
File "app/storage.py", line 41, in save
self._path.write_text(text, encoding="utf-8")
OSError: [Errno 28] No space left on device: 'notes.json'
Другий варіант відповідає на питання «що саме сталося». Перший — ні.
Куди писати лог: QStandardPaths і ротація¶
logging.FileHandler("app.log") створює файл у робочому каталозі. Це погано з двох причин: робочий каталог залежить від того, звідки запустили програму, а каталог із запакованим застосунком часто взагалі доступний лише для читання.
Правильне місце дає Qt — клас QStandardPaths. Він знає, куди операційна система дозволяє писати:
import sys
from PySide6.QtCore import QCoreApplication, QStandardPaths
app = QCoreApplication(sys.argv)
QCoreApplication.setOrganizationName("KTBP")
QCoreApplication.setApplicationName("Notes")
for name in ("AppConfigLocation", "AppDataLocation", "CacheLocation", "TempLocation"):
location = getattr(QStandardPaths, name)
print(f"{name:20} {QStandardPaths.writableLocation(location)}")
На Linux виведе:
AppConfigLocation /home/user/.config/KTBP/Notes
AppDataLocation /home/user/.local/share/KTBP/Notes
CacheLocation /home/user/.cache/KTBP/Notes
TempLocation /tmp
Той самий код на macOS дасть шляхи всередині ~/Library. Ви не пишете жодного if про операційну систему — Qt робить це за вас.
setOrganizationName і setApplicationName обов'язкові
Без них шляхи будуть безіменні, а QSettings (наступна половина лекції) писатиме в загальне сміттєзвалище. Ці два рядки ставлять першими після створення QApplication — до будь-якого звернення до QStandardPaths чи QSettings.
Лог має рости обмежено. Застосунок, який пише лог рік, з'їсть диск. Стандартна відповідь — ротація: файл до заданого розміру, потім перейменування й новий файл.
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
from PySide6.QtCore import QCoreApplication, QStandardPaths
def setup_logging(level=logging.INFO):
"""Налаштовує лог у каталозі застосунку. Повертає шлях до файла."""
base = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)
log_dir = Path(base) / "logs"
log_dir.mkdir(parents=True, exist_ok=True) # каталогу може ще не бути
log_path = log_dir / "app.log"
file_handler = RotatingFileHandler(
log_path,
maxBytes=1_000_000, # 1 МБ на файл
backupCount=3, # app.log, app.log.1, app.log.2, app.log.3
encoding="utf-8",
)
file_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s: %(message)s")
)
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setFormatter(logging.Formatter("%(levelname)-8s %(name)s: %(message)s"))
root = logging.getLogger()
root.setLevel(level)
root.handlers.clear() # захист від подвійного налаштування
root.addHandler(file_handler)
root.addHandler(console_handler)
return log_path
def main():
app = QCoreApplication(sys.argv)
QCoreApplication.setOrganizationName("KTBP")
QCoreApplication.setApplicationName("Notes")
log_path = setup_logging(logging.DEBUG)
logger = logging.getLogger(__name__)
logger.info("Log file: %s", log_path)
logger.debug("Debug messages are visible")
logger.warning("Something looks odd")
try:
1 / 0
except ZeroDivisionError:
logger.exception("Division failed")
print(f"Written to {log_path}")
if __name__ == "__main__":
main()
Запустіть — і подивіться отриманий файл. Максимум на диску: maxBytes * (backupCount + 1) = 4 МБ.
Дайте користувачу дістатись до лога
Коли щось ламається, ви попросите користувача надіслати лог. Знайти ~/.local/share/KTBP/Notes/logs/ він не зможе. Додайте в меню Help пункт Open log folder:
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl.fromLocalFile(str(log_path.parent)))
Це відкриє каталог у файловому менеджері системи — одним пунктом меню замість десяти хвилин пояснень.
Повідомлення самого Qt у тому ж лозі¶
Qt друкує власні попередження прямо в stderr, повз logging:
У файл лога вони не потраплять, і при розборі проблеми ви їх не побачите. Функція qInstallMessageHandler дозволяє перенаправити їх у logging:
import logging
import sys
from PySide6.QtCore import QCoreApplication, QtMsgType, qInstallMessageHandler, qWarning
_QT_LEVELS = {
QtMsgType.QtDebugMsg: logging.DEBUG,
QtMsgType.QtInfoMsg: logging.INFO,
QtMsgType.QtWarningMsg: logging.WARNING,
QtMsgType.QtCriticalMsg: logging.ERROR,
QtMsgType.QtFatalMsg: logging.CRITICAL,
}
def install_qt_message_handler():
"""Перенаправляє повідомлення Qt у стандартний logging."""
qt_logger = logging.getLogger("qt")
def handler(mode, context, message):
level = _QT_LEVELS.get(mode, logging.INFO)
if context.file:
qt_logger.log(level, "%s (%s:%s)", message, context.file, context.line)
else:
qt_logger.log(level, "%s", message)
qInstallMessageHandler(handler)
def main():
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
install_qt_message_handler()
app = QCoreApplication(sys.argv)
qWarning("test warning from Qt")
logging.getLogger(__name__).info("Application logger works too")
if __name__ == "__main__":
main()
Виведе:
2026-09-08 10:22:03,451 WARNING qt: test warning from Qt
2026-09-08 10:22:03,451 INFO __main__: Application logger works too
Тепер попередження Qt і ваші повідомлення лежать в одному файлі, в одному хронологічному порядку — при розборі помилки це часто вирішальне.
Як показати помилку користувачу¶
Лог — для вас. Користувачу потрібен інший канал, і він залежить від ваги проблеми та від того, чи він зараз чекає на результат.
| Спосіб | Коли доречно | Приклад |
|---|---|---|
Рядок стану (statusBar) |
дрібниця, дію можна повторити | Autosave failed, will retry |
| Смужка (панель) у вікні | помилка стосується того, що на екрані | File is read-only над редактором |
| Підказка біля поля | некоректне введення | Title must not be empty |
QMessageBox.warning |
користувач чекав на результат, результату немає | Cannot save the file |
QMessageBox.critical |
застосунок далі працювати не може | Cannot create data folder |
| Нічого, тільки лог | користувач не помітить і не постраждає | не оновилась іконка в треї |
Головне правило: одна помилка — одне повідомлення. Не показуйте QMessageBox у циклі. Якщо не скопіювалось 200 файлів із 500, покажіть один діалог з підсумком і списком у setDetailedText().
QMessageBox має чотири текстові поля, і вони не взаємозамінні:
import sys
from PySide6.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
def show_save_error(parent, path, error):
"""Повне повідомлення про помилку: коротко, детальніше, технічно."""
box = QMessageBox(parent)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("Notes")
box.setText("Cannot save the file.") # головний рядок, жирний
box.setInformativeText( # що робити далі
"Your notes are still open and unsaved. "
"Try saving to another folder."
)
box.setDetailedText(f"Path: {path}\n{type(error).__name__}: {error}")
box.setStandardButtons(QMessageBox.Save | QMessageBox.Cancel)
box.setDefaultButton(QMessageBox.Save)
box.button(QMessageBox.Save).setText("Save as...")
return box.exec()
class Window(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Error dialogs")
warn_button = QPushButton("Simulate save error")
warn_button.clicked.connect(self.simulate)
quick_button = QPushButton("Quick question")
quick_button.clicked.connect(self.ask)
layout = QVBoxLayout(self)
layout.addWidget(warn_button)
layout.addWidget(quick_button)
def simulate(self):
error = PermissionError(13, "Permission denied")
answer = show_save_error(self, "/read-only/notes.json", error)
if answer == QMessageBox.Save:
print("User chose Save as...")
else:
print("User cancelled")
def ask(self):
answer = QMessageBox.question(
self,
"Notes",
"Discard unsaved changes?",
QMessageBox.Discard | QMessageBox.Cancel,
QMessageBox.Cancel, # безпечна кнопка — типова
)
print("Discard" if answer == QMessageBox.Discard else "Cancel")
def main():
app = QApplication(sys.argv)
window = Window()
window.resize(280, 120)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Розподіл ролей:
setText— одне речення про те, що сталося. Це єдине, що прочитають.setInformativeText— що робити далі. Часто корисніше за перше.setDetailedText— технічні подробиці під кнопкоюShow Details.... Сюди кладуть шлях, тип винятку, traceback. Користувач не побачить, поки не захоче, а в баг-репорт скопіює одним рухом.setStandardButtons+setDefaultButton— типовою робіть безпечну кнопку. Якщо користувач натиснеEnterне дивлячись, він не має втратити дані.
QMessageBox не показують без батька
QMessageBox.warning(None, ...) створює вікно, яке може відкритись за головним вікном або на іншому моніторі. Перший аргумент — self (вікно-батько), тоді діалог центрується на ньому й блокує саме його.
Ненав'язливий варіант. Для помилок, які не вимагають рішення, модальний діалог занадто грубий: він зупиняє роботу. Смужка над вмістом набагато делікатніша.
import sys
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
class MessageBar(QFrame):
"""Смужка з повідомленням, яка з'являється над вмістом вікна."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet("background: #fdf0d5; border: 1px solid #d9a441;")
self.hide()
self._label = QLabel()
self._label.setWordWrap(True)
close_button = QToolButton()
close_button.setText("x")
close_button.clicked.connect(self.hide)
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 4, 4, 4)
layout.addWidget(self._label, 1)
layout.addWidget(close_button)
def show_message(self, text, timeout_ms=0):
self._label.setText(text)
self.show()
if timeout_ms > 0:
QTimer.singleShot(timeout_ms, self.hide)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Message bar")
self.bar = MessageBar()
editor = QTextEdit()
fail_button = QPushButton("Simulate autosave failure")
fail_button.clicked.connect(self.on_fail)
central = QWidget()
layout = QVBoxLayout(central)
layout.addWidget(self.bar)
layout.addWidget(editor, 1)
layout.addWidget(fail_button)
self.setCentralWidget(central)
self.statusBar().showMessage("Ready")
def on_fail(self):
self.bar.show_message("Autosave failed: disk is full. Your text is still here.")
self.statusBar().showMessage("Autosave failed", 5000)
def main():
app = QApplication(sys.argv)
window = MainWindow()
window.resize(520, 320)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Смужка не блокує роботу, не потребує натискання OK і зникає, коли перестала бути актуальною. Для повторюваних дрібних збоїв це набагато краще за діалог.
Текст повідомлення: що писати, а чого не писати¶
Технічно правильне повідомлення може бути марним.
| Погано | Чому | Краще |
|---|---|---|
Error |
нічого не сказано | Cannot save "notes.json" |
Exception occurred in module 4 |
мова розробника | The file is used by another program |
[Errno 13] Permission denied |
код помилки замість пояснення | No permission to write to this folder. Choose another one. |
Operation failed. Contact administrator. |
адміністратора не існує | Cannot reach the server. Check your connection and try again. |
NoneType object has no attribute title |
це баг, а не повідомлення | Unexpected error. Details are in the log file. |
Робочий шаблон із трьох частин:
- Що не вдалося — конкретна дія, конкретний об'єкт:
Cannot open "report.json". - Чому — причина людською мовою:
the file is damaged. - Що робити — дія, доступна користувачу:
Choose another file or restore it from a backup.
Три речення, а не одне слово Error.
Мова інтерфейсу
У цьому курсі всі рядки в коді — латиницею (див. вимоги до практичних). У реальному застосунку тексти помилок беруть із системи перекладу (tr()), а не пишуть у коді жорстко. Це тема окремої розмови; правило про три частини не залежить від мови.
Чого не показувати ніколи: абсолютних шляхів усередині вашого пакета, вмісту змінних, назв внутрішніх класів, повного traceback у setText. Усе це — у лог і в setDetailedText.
Не ловіть того, чого можна не допустити: валідація¶
Найдешевша обробка помилки — та, якої не знадобилось. Некоректне введення перевіряють до дії, а не ловлять ValueError після.
Рівень 1: не дати ввести неправильне. QLineEdit приймає валідатор:
import sys
from PySide6.QtCore import QRegularExpression, Qt
from PySide6.QtGui import QIntValidator, QRegularExpressionValidator
from PySide6.QtWidgets import (
QApplication,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
class Window(QWidget):
"""Валідатори не дають ввести те, чого не можна."""
def __init__(self):
super().__init__()
self.setWindowTitle("Validation")
self.age_edit = QLineEdit()
self.age_edit.setValidator(QIntValidator(1, 120, self))
self.age_edit.setPlaceholderText("1 .. 120")
self.code_edit = QLineEdit()
self.code_edit.setValidator(
QRegularExpressionValidator(QRegularExpression(r"[A-Z]{2}-\d{3}"), self)
)
self.code_edit.setPlaceholderText("AB-123")
self.title_edit = QLineEdit()
self.title_edit.setMaxLength(60)
self.error_label = QLabel()
self.error_label.setStyleSheet("color: #b00020;")
submit_button = QPushButton("Submit")
submit_button.clicked.connect(self.on_submit)
form = QFormLayout()
form.addRow("Title:", self.title_edit)
form.addRow("Age:", self.age_edit)
form.addRow("Code:", self.code_edit)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(self.error_label)
layout.addWidget(submit_button)
def on_submit(self):
error = self.validate()
if error:
field, message = error
self.error_label.setText(message)
field.setFocus()
field.selectAll()
return
self.error_label.setText("")
print("Accepted:", self.title_edit.text(), self.age_edit.text(), self.code_edit.text())
def validate(self):
"""Повертає (поле, повідомлення) для першої проблеми або None."""
if not self.title_edit.text().strip():
return self.title_edit, "Title must not be empty."
if not self.age_edit.hasAcceptableInput():
return self.age_edit, "Age must be a number between 1 and 120."
if not self.code_edit.hasAcceptableInput():
return self.code_edit, "Code must look like AB-123."
return None
def main():
app = QApplication(sys.argv)
window = Window()
window.resize(360, 200)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Спробуйте ввести літеру в поле Age — вона просто не з'явиться. Валідатор працює на рівні клавіатури.
Рівень 2: перевірити перед дією. Валідатор гарантує лише формат кожного поля окремо. Порожнє поле він пропускає (порожній рядок — «проміжний» стан), як і суперечності між полями («дата кінця раніша за дату початку»). Тому метод validate() існує все одно.
Зверніть увагу на дві деталі: hasAcceptableInput() питає у валідатора, чи повний зараз вміст поля; setFocus() і selectAll() ставлять курсор саме туди, де проблема. Показати повідомлення й лишити користувача самого шукати поле — половина роботи.
Рівень 3: не давати натиснути. Найм'якший варіант — кнопка Submit вимкнена, поки форма неповна:
self.title_edit.textChanged.connect(self.update_submit_state)
...
def update_submit_state(self):
self.submit_button.setEnabled(bool(self.title_edit.text().strip()))
Правило вибору: вимикати кнопку — коли неповнота очевидна з екрана; показувати повідомлення — коли причина неочевидна. Вимкнена кнопка без пояснення, чому вона вимкнена, дратує сильніше за помилку.
Валідація — не заміна try
Ви перевірили, що файл існує, — і між перевіркою й відкриттям його видалили. Перевірка зменшує кількість помилок, але не робить try зайвим. Особливо це стосується файлів і мережі.
Помилки у фоновому потоці¶
З лекції 20 відомо, що довга робота живе у воркері. Виняток у воркері має власну неприємну властивість: try головного потоку його не спіймає.
start() лише запускає потік і одразу повертається. Виняток станеться пізніше й в іншому потоці — цей try уже давно завершився.
Наслідок без обробки: воркер помирає, сигнал finished не приходить, вікно назавжди лишається в стані Working..., кнопка Start вимкнена. Найгірший різновид помилки — та, після якої застосунок просто зависає.
Правило: run() завжди обгортають try, і у воркера завжди є сигнал failed.
import sys
from PySide6.QtCore import QObject, QThread, Signal
from PySide6.QtWidgets import (
QApplication,
QLabel,
QProgressBar,
QPushButton,
QVBoxLayout,
QWidget,
)
STEP_COUNT = 10
class Worker(QObject):
"""Довга робота, яка вміє повідомити про власну помилку."""
progress = Signal(int)
finished = Signal(int)
failed = Signal(str)
def __init__(self, fail_at=-1):
super().__init__()
self._fail_at = fail_at
def run(self):
try:
total = 0
for step in range(1, STEP_COUNT + 1):
if step == self._fail_at:
raise RuntimeError(f"Cannot process step {step}")
QThread.currentThread().msleep(150)
total += step
self.progress.emit(step)
except Exception as error: # ловимо все: потік не має вмирати мовчки
self.failed.emit(f"{type(error).__name__}: {error}")
else:
self.finished.emit(total)
class Window(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Errors in a worker")
self.thread = None
self.worker = None
self.status = QLabel("Ready")
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, STEP_COUNT)
self.ok_button = QPushButton("Run (succeeds)")
self.ok_button.clicked.connect(lambda: self.start(-1))
self.fail_button = QPushButton("Run (fails at step 4)")
self.fail_button.clicked.connect(lambda: self.start(4))
layout = QVBoxLayout(self)
layout.addWidget(self.status)
layout.addWidget(self.progress_bar)
layout.addWidget(self.ok_button)
layout.addWidget(self.fail_button)
def start(self, fail_at):
self.set_busy(True)
self.status.setText("Working...")
self.progress_bar.setValue(0)
self.thread = QThread()
self.worker = Worker(fail_at)
self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run)
self.worker.progress.connect(self.progress_bar.setValue)
self.worker.finished.connect(self.on_finished)
self.worker.failed.connect(self.on_failed)
self.worker.finished.connect(self.thread.quit)
self.worker.failed.connect(self.thread.quit) # обидва шляхи зупиняють потік
self.thread.finished.connect(self.on_thread_finished)
self.thread.start()
def on_finished(self, total):
self.status.setText(f"Done: {total}")
def on_failed(self, message):
self.status.setText(f"Failed: {message}")
def on_thread_finished(self):
self.set_busy(False) # вмикаємо кнопки в обох випадках
self.worker = None
self.thread = None
def set_busy(self, busy):
self.ok_button.setEnabled(not busy)
self.fail_button.setEnabled(not busy)
def closeEvent(self, event):
if self.thread is not None and self.thread.isRunning():
self.thread.quit()
self.thread.wait(3000)
super().closeEvent(event)
def main():
app = QApplication(sys.argv)
window = Window()
window.resize(360, 180)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Ключові рішення:
except Exceptionу воркері доречний. Це той рідкісний випадок, коли ловлять усе: невідомий виняток тут означає зависле вікно. Але його не ковтають — його надсилають сигналом.failedтеж зупиняє потік. Якщоquitпід'єднано лише доfinished, після помилки потік лишиться жити.- Кнопки вмикає
thread.finished. Це «finallyна рівні сигналів»: сигнал приходить і після успіху, і після помилки, тому інтерфейс відновиться в обох випадках. - Об'єкт винятку сигналом не передають. Передають рядок (або кортеж
(тип, текст)). Виняток тягне за собою traceback, а traceback — посилання на кадри стека чужого потоку; така конструкція живе довше, ніж треба, і тримає в пам'яті все, що було в тих кадрах.
Traceback при цьому не губиться — його записує сам воркер:
except Exception as error:
logger.exception("Worker failed") # повний traceback у файл
self.failed.emit(f"{type(error).__name__}: {error}")
sys.excepthook не рятує фоновий потік
Для потоків, створених через threading.Thread, є окремий threading.excepthook. Для QThread із воркером надійний спосіб один — try у run(). Не покладайтесь на глобальний хук там, де можна поставити локальний try.
Надійне збереження: атомарний запис і резервна копія¶
Найдорожча помилка застосунку — та, що знищує дані користувача. І найлегше її отримати саме там, де здається, що все просто:
write_text спочатку обрізає файл до нуля, а потім пише. Якщо на другому кроці станеться збій — скінчилось місце, вимкнулось живлення, впав процес — на диску лишиться порожній або обрізаний файл. Старих даних більше немає.
Правильний спосіб — атомарний запис: пишемо в тимчасовий файл поруч, і лише коли запис повністю завершився, перейменовуємо його на місце основного. Перейменування в межах однієї файлової системи — атомарна операція: або старий файл, або новий, третього стану не буває.
import json
import os
import sys
from pathlib import Path
class StorageError(Exception):
"""Помилка шару збереження."""
class JsonStorage:
"""Читає й пише список записів у JSON із атомарним записом і копією."""
def __init__(self, path):
self._path = Path(path)
def load(self):
if not self._path.exists():
return []
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except OSError as error:
raise StorageError(f"Cannot read {self._path}: {error}") from error
except json.JSONDecodeError as error:
raise StorageError(
f"File {self._path} is damaged (line {error.lineno})"
) from error
if not isinstance(raw, list):
raise StorageError(f"Unexpected format in {self._path}: list expected")
return raw
def save(self, records):
text = json.dumps(records, ensure_ascii=False, indent=2)
temp_path = self._path.with_suffix(self._path.suffix + ".tmp")
backup_path = self._path.with_suffix(self._path.suffix + ".bak")
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
with open(temp_path, "w", encoding="utf-8") as file:
file.write(text)
file.flush() # з буфера Python у буфер системи
os.fsync(file.fileno()) # з буфера системи на диск
if self._path.exists():
os.replace(self._path, backup_path) # попередня версія лишається
os.replace(temp_path, self._path) # атомарна підміна
except OSError as error:
raise StorageError(f"Cannot write {self._path}: {error}") from error
finally:
temp_path.unlink(missing_ok=True) # прибираємо сміття за собою
def main():
storage = JsonStorage("demo_data/notes.json")
storage.save([{"id": 1, "title": "First"}, {"id": 2, "title": "Second"}])
print("Saved:", storage.load())
storage.save([{"id": 1, "title": "First (edited)"}])
print("Saved again:", storage.load())
print("Backup kept:", Path("demo_data/notes.json.bak").read_text(encoding="utf-8"))
Path("demo_data/notes.json").write_text("{ broken", encoding="utf-8")
try:
storage.load()
except StorageError as error:
print("Expected failure:", error)
if __name__ == "__main__":
sys.exit(main())
Що дає ця схема:
.tmp+os.replace— основний файл ніколи не буває в наполовину записаному стані;.bak— попередня версія лишається на диску, і при пошкодженні є що відновлювати;fsync— гарантія, що дані справді на диску, а не в кеші системи;finallyзunlink— після невдалого запису поруч не лишається.tmp-сміття;os.replace, а неos.rename—replaceперезаписує наявний файл на всіх платформах однаково.
Автозбереження + .bak = майже жодних втрат
Комбінація «зберігати за таймером кожні N секунд, якщо є зміни» плюс резервна копія покриває переважну більшість сценаріїв втрати даних у навчальних і невеликих застосунках. Складніші схеми (журнал, версії) потрібні там, де дані коштують грошей.
Природне питання: чи не варто виносити збереження у фоновий потік? Для файла на кілька сотень кілобайтів — ні: запис триває одиниці мілісекунд, а складність зросте помітно. Виносьте, коли запис справді довгий (десятки мегабайтів, мережа, база) — і тоді за схемою з попереднього розділу, із сигналом failed.
Налаштування: що це і чим воно не є¶
Друга половина лекції — про те, що застосунок пам'ятає між запусками.
Закрийте будь-який редактор і відкрийте знову. Вікно того самого розміру й на тому самому місці, той самий шрифт, той самий список останніх файлів, та сама тема. Нічого з цього не з'явилось саме — усе це налаштування, збережені при виході й прочитані при старті.
Важливо не плутати три різні речі:
| Що це | Хто змінює | Де живе | |
|---|---|---|---|
| Дані | нотатки, контакти, товари | користувач, свідомо | файл документа, база |
| Налаштування | тема, шрифт, автозбереження, мова | користувач, у діалозі Preferences |
QSettings / файл конфігурації |
| Стан інтерфейсу | розмір вікна, позиція, ширина колонок, останній каталог | застосунок, непомітно | QSettings |
Різниця між першим і рештою — принципова. Втрата даних — катастрофа. Втрата налаштувань — незручність. Тому дані зберігають атомарно з резервною копією, а налаштування — просто зберігають; якщо файл налаштувань зіпсувався, застосунок бере типові значення й пише WARNING у лог. Він не має через це відмовлятись запускатись.
Звідси випливають два правила, які видно в коді:
- Дані й налаштування — різні файли. Не кладіть розмір вікна в
notes.json. - Кожне налаштування має значення за замовчуванням, і застосунок повністю працездатний, коли файла налаштувань немає взагалі.
QSettings: перше знайомство¶
Qt дає готовий клас QSettings, який ховає всі відмінності між платформами: на Linux це файл .conf, на macOS — plist, на Windows — реєстр. Ваш код однаковий.
import sys
from PySide6.QtCore import QCoreApplication, QSettings
def main():
app = QCoreApplication(sys.argv)
QCoreApplication.setOrganizationName("KTBP")
QCoreApplication.setApplicationName("Notes")
settings = QSettings()
print("Settings file:", settings.fileName())
settings.setValue("ui/theme", "dark")
settings.setValue("ui/font_size", 12)
settings.setValue("editor/autosave", True)
settings.sync()
print("All keys:", settings.allKeys())
print("Theme:", settings.value("ui/theme", "light"))
print("Missing key:", settings.value("ui/language", "en"))
print("Contains 'ui/theme':", settings.contains("ui/theme"))
settings.remove("editor/autosave")
print("After remove:", settings.allKeys())
if __name__ == "__main__":
main()
Виведе (на Linux):
Settings file: /home/user/.config/KTBP/Notes.conf
All keys: ['editor/autosave', 'ui/font_size', 'ui/theme']
Theme: dark
Missing key: en
Contains 'ui/theme': True
After remove: ['ui/font_size', 'ui/theme']
А сам файл виглядає так:
Що тут відбулося:
QSettings()без аргументів бере назву організації й застосунку зQCoreApplication. Якщо їх не встановити, шлях буде безіменним і ви не знайдете власні налаштування.- Коса риска в ключі (
ui/theme) — це група. У файлі вона перетворюється на секцію[ui]. value(key, default)ніколи не кидаєKeyError: немає ключа — повертається типове значення. Це головна причина, чому налаштування такі зручні.sync()записує зміни на диск негайно. Взагалі-тоQSettingsробить це сам — при знищенні об'єкта й періодично, — але при виході з застосунку явнийsync()не зайвий.
QSettings можна створювати скільки завгодно разів. Це не сховище, яке треба передавати між об'єктами: усі екземпляри з однаковими організацією/застосунком дивляться в один файл і мають спільний кеш.
Явний файл, коли потрібен свій формат або своє розташування:
IniFormat корисний і тоді, коли ви хочете однаковий текстовий файл на всіх системах — наприклад, щоб користувач міг відредагувати його вручну або надіслати вам.
Типи значень і головна пастка value()¶
Ось код, який виглядає правильним і працює неправильно:
settings = QSettings()
size = settings.value("ui/font_size", 12)
font.setPointSize(size + 2) # TypeError!
Причина в тому, що NativeFormat на Linux і IniFormat скрізь зберігають значення як текст. При читанні в новому процесі QSettings не знає, що 12 мало бути числом, і повертає рядок '12'.
Перевірте самі — двома окремими запусками. Спочатку запис:
import sys
from PySide6.QtCore import QCoreApplication, QSettings
app = QCoreApplication(sys.argv)
settings = QSettings("demo_settings.ini", QSettings.IniFormat)
settings.setValue("font_size", 12)
settings.setValue("autosave", True)
settings.setValue("recent", ["a.json", "b.json"])
settings.setValue("only_one", ["single.json"])
settings.sync()
print("Written to", settings.fileName())
Потім, окремим запуском, читання:
import sys
from PySide6.QtCore import QCoreApplication, QSettings
app = QCoreApplication(sys.argv)
settings = QSettings("demo_settings.ini", QSettings.IniFormat)
print("font_size ", repr(settings.value("font_size")))
print("font_size, default ", repr(settings.value("font_size", 10)))
print("font_size, type=int", repr(settings.value("font_size", 10, type=int)))
print("autosave ", repr(settings.value("autosave")))
print("autosave, type=bool", repr(settings.value("autosave", False, type=bool)))
print("recent ", repr(settings.value("recent")))
print("only_one ", repr(settings.value("only_one")))
Результат:
font_size '12'
font_size, default '12'
font_size, type=int 12
autosave 'true'
autosave, type=bool True
recent ['a.json', 'b.json']
only_one 'single.json'
Розберемо чотири уроки з цього виводу.
1. Тип значення за замовчуванням нічого не перетворює. value("font_size", 10) повернуло рядок '12', хоча типове значення — число. Типове значення використовується лише тоді, коли ключа немає.
2. Перетворення робить іменований аргумент type=:
font_size = settings.value("ui/font_size", 12, type=int)
autosave = settings.value("editor/autosave", True, type=bool)
theme = settings.value("ui/theme", "light", type=str)
Пишіть type= завжди — навіть коли на вашій системі й без нього працює. Той самий код на іншій платформі поводиться інакше, і це один із найпоширеніших джерел «у мене працює».
3. Рядок 'true' — це True у сенсі Python. Найковарніша пастка всієї теми:
Тут 'false' — непорожній рядок, тобто істина. Автозбереження, яке користувач вимкнув, лишиться увімкненим. type=bool перетворює правильно: 'true' → True, 'false' → False.
4. Список з одного елемента повертається рядком. У ini-файлі список пишеться як a.json, b.json; при читанні один елемент без коми не відрізнити від звичайного рядка. Тому список із двох елементів прийшов списком, а з одного — рядком, і for path in recent пробіжить по літерах.
Три способи це обійти:
# 1) вимагати список явно
recent = settings.value("recent", [], type=list)
# 2) захиститись при читанні
recent = settings.value("recent", [])
if isinstance(recent, str):
recent = [recent]
# 3) зберігати як JSON-рядок — надійно й читабельно
settings.setValue("recent", json.dumps(recent_list))
recent = json.loads(settings.value("recent", "[]", type=str))
Третій варіант надійний і для вкладених структур, які QSettings узагалі не вміє.
Не зберігайте в QSettings дані
Список нотаток, таблиця товарів, вміст документа — не налаштування. QSettings не має ані транзакцій, ані резервних копій, і файл конфігурації на кілька мегабайтів — верна ознака того, що щось пішло не туди.
Групи та масиви¶
Коли ключів багато, префікси набридає повторювати. beginGroup / endGroup роблять це за вас:
settings.beginGroup("window")
settings.setValue("width", 1024) # реальний ключ: window/width
settings.setValue("height", 768)
settings.setValue("maximized", False)
settings.endGroup()
settings.beginGroup("window")
width = settings.value("width", 800, type=int)
settings.endGroup()
Кожен beginGroup вимагає endGroup. Забутий endGroup призводить до того, що всі наступні ключі отримають чужий префікс, — знайти таку помилку важко. Надійніше загорнути це в менеджер контексту:
from contextlib import contextmanager
@contextmanager
def settings_group(settings, name):
settings.beginGroup(name)
try:
yield settings
finally:
settings.endGroup()
# використання
with settings_group(settings, "window") as group:
width = group.value("width", 800, type=int)
Для впорядкованих наборів однорідних записів є масиви — вони розв'язують проблему списків із попереднього розділу:
import sys
from PySide6.QtCore import QCoreApplication, QSettings
RECENT_LIMIT = 5
def save_recent(settings, paths):
settings.beginWriteArray("recent", len(paths))
for index, path in enumerate(paths):
settings.setArrayIndex(index)
settings.setValue("path", path)
settings.endArray()
def load_recent(settings):
paths = []
count = settings.beginReadArray("recent")
for index in range(count):
settings.setArrayIndex(index)
value = settings.value("path", "", type=str)
if value:
paths.append(value)
settings.endArray()
return paths
def add_recent(settings, path):
"""Ставить шлях першим, прибирає дублікати, обрізає до ліміту."""
paths = load_recent(settings)
if path in paths:
paths.remove(path)
paths.insert(0, path)
save_recent(settings, paths[:RECENT_LIMIT])
def main():
app = QCoreApplication(sys.argv)
settings = QSettings("demo_recent.ini", QSettings.IniFormat)
for path in ["a.json", "b.json", "c.json", "a.json"]:
add_recent(settings, path)
settings.sync()
print("Recent files:", load_recent(settings))
print("File:", settings.fileName())
if __name__ == "__main__":
main()
У файлі це виглядає так:
beginWriteArray сам записує ключ size, а beginReadArray його читає й повертає кількість. Одноелементний масив прочитається правильно — на відміну від простого списку.
beginWriteArray не чистить зайве
Якщо раніше в масиві було 5 елементів, а тепер ви записали 3, ключі 4\path і 5\path лишаться у файлі. size дорівнюватиме 3, тому читання буде правильним, але сміття накопичуватиметься. Прибирати явно: settings.remove("recent") перед записом.
Геометрія вікна й стан QMainWindow¶
Найпомітніше налаштування — те, що вікно відкривається там і таким, яким його лишили.
Наївний підхід — зберегти чотири числа:
settings.setValue("window/x", self.x())
settings.setValue("window/y", self.y())
settings.setValue("window/width", self.width())
settings.setValue("window/height", self.height())
Він працює погано: не переживає розгортання вікна, не пам'ятає, на якому моніторі воно було, і при від'єднанні монітора відкриє вікно за межами екрана — де користувач його не дістане.
Qt має готову пару методів, які вирішують усе це:
saveGeometry()/restoreGeometry()— позиція, розмір, розгорнутість, монітор;saveState()/restoreState()— дляQMainWindow: розташування панелей інструментів і доків.
Обидва повертають QByteArray, який QSettings зберігає без жодних перетворень.
import sys
from PySide6.QtCore import QByteArray, QCoreApplication, QSettings, Qt
from PySide6.QtWidgets import (
QApplication,
QDockWidget,
QLabel,
QMainWindow,
QTextEdit,
QToolBar,
)
class MainWindow(QMainWindow):
"""Вікно, яке пам'ятає свій розмір, позицію й розкладку панелей."""
def __init__(self):
super().__init__()
self.setWindowTitle("Geometry demo")
self.setCentralWidget(QTextEdit())
toolbar = QToolBar("Main")
toolbar.setObjectName("main_toolbar") # обов'язково для saveState
toolbar.addAction("New")
toolbar.addAction("Open")
self.addToolBar(toolbar)
dock = QDockWidget("Notes")
dock.setObjectName("notes_dock") # обов'язково для saveState
dock.setWidget(QLabel("Drag me around, then restart"))
self.addDockWidget(Qt.LeftDockWidgetArea, dock)
self.statusBar().showMessage("Move the window, then close and start again")
self.restore_layout()
def restore_layout(self):
settings = QSettings()
geometry = settings.value("window/geometry", QByteArray(), type=QByteArray)
if not geometry.isEmpty():
self.restoreGeometry(geometry)
else:
self.resize(800, 500) # перший запуск
state = settings.value("window/state", QByteArray(), type=QByteArray)
if not state.isEmpty():
self.restoreState(state)
def save_layout(self):
settings = QSettings()
settings.setValue("window/geometry", self.saveGeometry())
settings.setValue("window/state", self.saveState())
def closeEvent(self, event):
self.save_layout()
super().closeEvent(event)
def main():
app = QApplication(sys.argv)
QCoreApplication.setOrganizationName("KTBP")
QCoreApplication.setApplicationName("GeometryDemo")
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Запустіть, перетягніть вікно, перетягніть док в інший бік, закрийте, запустіть знову — усе на місці.
Чотири речі, які легко зробити неправильно:
objectNameобов'язковий.saveState()запам'ятовує панелі й доки за їхніми іменами. БезsetObjectName()Qt виведе попередженняQMainWindow::saveState(): 'objectName' not setі розкладку не відновить.- Порядок відновлення: спочатку
restoreGeometry, потімrestoreState. У зворотному порядку доки отримають розміри від ще не зміненого вікна. - Перевірка на порожнечу. При першому запуску ключа немає;
restoreGeometry(QByteArray())повернеFalseі нічого не зробить, тож вікно матиме випадковий розмір. Гілкаelseз явнимresize()— це і є «розумний перший запуск». - Зберігати в
closeEvent, а не в__del__.closeEventвикликається гарантовано; деструктор Python — ні.
Пункт меню Reset window layout
Розкладку можна зіпсувати так, що вікно стане непридатним, — наприклад, витягнувши док за межі екрана. Пункт меню, який робить settings.remove("window") і просить перезапустити застосунок, коштує п'ять рядків і рятує від листів «у мене все зламалось».
Свій файл налаштувань: коли JSON кращий за QSettings¶
QSettings — не єдиний варіант. Іноді власний файл зручніший.
QSettings |
Свій JSON / TOML | |
|---|---|---|
| Розташування | системне, автоматично | обираєте самі |
| Формат | залежить від платформи | однаковий скрізь |
| Типи | плоскі, з пастками перетворення | будь-які, точні |
| Вкладені структури | ні | так |
| Читання людиною | так на Linux, ні на Windows | завжди |
| Копіювання між машинами | незручно | просто копія файла |
| Коментарі у файлі | ні | у TOML — так |
| Код | нуль рядків | ~40 рядків |
Правило вибору просте:
- Стан інтерфейсу (геометрія, ширина колонок, останні файли) —
QSettings. Ці дані машинні, читати їх людині не треба, а системне розташування — саме те, чого від них очікують. - Конфігурація (адреса сервера, шлях до даних, поріг, ключ API) — свій файл. Її редагують руками, кладуть у репозиторій, копіюють між машинами.
Робочий приклад свого шару налаштувань:
import json
import logging
import sys
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class Settings:
"""Налаштування застосунку з явними типами й типовими значеннями."""
theme: str = "light"
font_size: int = 12
autosave: bool = True
autosave_interval_s: int = 30
data_path: str = "notes.json"
recent_files: list = field(default_factory=list)
class SettingsStore:
"""Читає й пише Settings у JSON. Пошкоджений файл не валить застосунок."""
def __init__(self, path):
self._path = Path(path)
def load(self):
if not self._path.exists():
logger.info("No settings file, using defaults: %s", self._path)
return Settings()
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("Broken settings file, using defaults", exc_info=True)
return Settings()
if not isinstance(raw, dict):
logger.warning("Settings file is not an object, using defaults")
return Settings()
return self._from_dict(raw)
def save(self, settings):
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(asdict(settings), ensure_ascii=False, indent=2)
self._path.write_text(text, encoding="utf-8")
except OSError:
logger.warning("Cannot save settings to %s", self._path, exc_info=True)
return False
return True
@staticmethod
def _from_dict(raw):
"""Бере лише відомі ключі з очікуваним типом. Решту ігнорує."""
settings = Settings()
for item in fields(Settings):
if item.name not in raw:
continue
value = raw[item.name]
expected_type = type(getattr(settings, item.name))
# bool є підкласом int, тому порівнюємо типи точно, а не через isinstance
if type(value) is expected_type:
setattr(settings, item.name, value)
else:
logger.warning(
"Ignoring setting %r: expected %s, got %r",
item.name,
expected_type.__name__,
value,
)
return settings
def main():
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
store = SettingsStore("demo_config/settings.json")
settings = store.load()
print("Loaded:", settings)
settings.theme = "dark"
settings.font_size = 14
settings.recent_files = ["a.json", "b.json"]
store.save(settings)
print("Saved:", store.load())
Path("demo_config/settings.json").write_text("{ broken", encoding="utf-8")
print("After damage:", store.load())
if __name__ == "__main__":
sys.exit(main())
Три властивості цього коду варто відзначити.
Пошкоджений файл не валить застосунок. load() не кидає винятків узагалі: будь-яка проблема — це WARNING у лозі й типові налаштування. Порівняйте зі сховищем даних із попереднього розділу, яке кидає StorageError: там мовчазна підміна на порожній список була б втратою даних, тут — правильна поведінка.
Невідомі ключі ігноруються. Користувач відкрив файл і дописав щось своє; стара версія застосунку зустріла ключ від нової. Обидві ситуації нормальні, і жодна не має ламати запуск.
dataclass замість словника. settings.font_size перевіряється редактором і не має шансу перетворитись на settings.fontsize. Словник settings["font_size"] такої перевірки не дає, а KeyError через одруківку ви побачите тільки на запуску.
TOML замість JSON
Якщо файл редагують люди, TOML приємніший: у ньому є коментарі. Читати його вміє стандартний модуль tomllib (Python 3.11+), але тільки читати — записувати доведеться вручну або сторонньою бібліотекою. Тому TOML добре пасує для конфігурації, яку застосунок лише читає, а JSON — для тієї, яку він і читає, і пише.
Шар налаштувань в архітектурі¶
Головна помилка з налаштуваннями — розсипати QSettings() по всьому коду:
Наслідки ті самі, що й у будь-якого глобального стану: рядок-ключ "ui/font_size" існує в кількох файлах (і колись одна з копій стане "ui/fontsize"), типове значення 12 теж дублюється, панель неможливо протестувати без справжнього QSettings, а знайти всі місця, які читають налаштування, можна лише пошуком по проєкту.
Правило те саме, що для сховища даних із лекції 14: звертається до QSettings один клас; решта отримує його як залежність.
import sys
from PySide6.QtCore import QByteArray, QCoreApplication, QObject, QSettings, Signal
class AppSettings(QObject):
"""Єдина точка доступу до налаштувань. Знає ключі й типові значення."""
changed = Signal()
THEMES = ("light", "dark")
def __init__(self, settings=None):
super().__init__()
self._settings = settings if settings is not None else QSettings()
# --- налаштування користувача -----------------------------------------
@property
def theme(self):
value = self._settings.value("ui/theme", "light", type=str)
return value if value in self.THEMES else "light" # захист від сміття
@theme.setter
def theme(self, value):
if value not in self.THEMES:
raise ValueError(f"Unknown theme: {value}")
self._settings.setValue("ui/theme", value)
self.changed.emit()
@property
def font_size(self):
value = self._settings.value("ui/font_size", 12, type=int)
return min(max(value, 8), 32) # межі — теж захист
@font_size.setter
def font_size(self, value):
self._settings.setValue("ui/font_size", int(value))
self.changed.emit()
@property
def autosave(self):
return self._settings.value("editor/autosave", True, type=bool)
@autosave.setter
def autosave(self, value):
self._settings.setValue("editor/autosave", bool(value))
self.changed.emit()
# --- стан інтерфейсу ---------------------------------------------------
def window_geometry(self):
return self._settings.value("window/geometry", QByteArray(), type=QByteArray)
def set_window_geometry(self, data):
self._settings.setValue("window/geometry", data)
def sync(self):
self._settings.sync()
def main():
app = QCoreApplication(sys.argv)
QCoreApplication.setOrganizationName("KTBP")
QCoreApplication.setApplicationName("SettingsLayerDemo")
settings = AppSettings()
settings.changed.connect(lambda: print("Settings changed"))
print("theme:", settings.theme, "| font:", settings.font_size, "| autosave:", settings.autosave)
settings.theme = "dark"
settings.font_size = 15
print("theme:", settings.theme, "| font:", settings.font_size)
try:
settings.theme = "neon"
except ValueError as error:
print("Rejected:", error)
settings.sync()
if __name__ == "__main__":
main()
Що це дає:
- Ключі й типові значення в одному місці. Одруківка неможлива:
settings.font_sizeабо є, або одразуAttributeErrorпри першому ж запуску. type=уже написаний. Жоден інший файл не має шансу забути про пастку з рядками.- Значення перевіряються на межах. Хтось відредагував файл руками й поставив
font_size=900— застосунок візьме32, а не намалює вікно з нечитабельним шрифтом. Налаштування — це зовнішні дані, і довіряти їм не можна так само, як введенню користувача. - Сигнал
changed. Вікно під'єднується один раз і перемальовується, коли налаштування змінились, — той самий односпрямований потік даних, що й у лекції 14. - Підміна в тестах.
AppSettings(QSettings("test.ini", QSettings.IniFormat))працює з тимчасовим файлом і нічого не псує в системі.
Один сигнал чи кілька
Починайте з одного changed. Розділяйте (theme_changed, font_changed), лише коли перемальовування стає помітно дорогим, — так само, як із сигналами стану в лекції 14.
Діалог налаштувань і застосування «наживо»¶
Останній елемент — вікно Preferences, у якому користувач ці налаштування змінює.
Головне питання його дизайну: коли зміни набувають чинності. Три поширені відповіді:
OK/Cancel— зміни застосовуються приOK,Cancelскасовує все. Передбачувано; стандарт для десктопу.- Одразу — кожна зміна діє негайно, кнопки
Cancelнемає. Так роблять сучасні мобільні застосунки й деякі десктопні (Firefox). Потребує, щоб кожна зміна була безпечною й оборотною. Apply— застосувати, не закриваючи. Корисно, коли результат видно одразу (шрифт, тема).
Реалізуємо перший варіант із живим попереднім переглядом — це і найзвичніше, і найповчальніше.
import sys
from PySide6.QtCore import QCoreApplication, QSettings, Qt, Signal
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QMainWindow,
QSpinBox,
QTextEdit,
QVBoxLayout,
)
class SettingsDialog(QDialog):
"""Preferences з OK / Cancel і живим попереднім переглядом."""
preview_requested = Signal(str, int)
def __init__(self, values, parent=None):
super().__init__(parent)
self.setWindowTitle("Preferences")
self.setModal(True)
self._start_values = dict(values)
self.theme_combo = QComboBox()
self.theme_combo.addItems(["light", "dark"])
self.theme_combo.setCurrentText(values["theme"])
self.font_spin = QSpinBox()
self.font_spin.setRange(8, 32)
self.font_spin.setSuffix(" pt")
self.font_spin.setValue(values["font_size"])
self.autosave_check = QCheckBox("Save changes automatically")
self.autosave_check.setChecked(values["autosave"])
self.interval_spin = QSpinBox()
self.interval_spin.setRange(5, 600)
self.interval_spin.setSuffix(" s")
self.interval_spin.setValue(values["autosave_interval_s"])
self.interval_spin.setEnabled(values["autosave"])
self.autosave_check.toggled.connect(self.interval_spin.setEnabled)
self.theme_combo.currentTextChanged.connect(self._emit_preview)
self.font_spin.valueChanged.connect(self._emit_preview)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form = QFormLayout()
form.addRow("Theme:", self.theme_combo)
form.addRow("Font size:", self.font_spin)
form.addRow("", self.autosave_check)
form.addRow("Autosave every:", self.interval_spin)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(QLabel("Changes are applied when you press OK."))
layout.addWidget(buttons)
def values(self):
return {
"theme": self.theme_combo.currentText(),
"font_size": self.font_spin.value(),
"autosave": self.autosave_check.isChecked(),
"autosave_interval_s": self.interval_spin.value(),
}
def _emit_preview(self):
self.preview_requested.emit(self.theme_combo.currentText(), self.font_spin.value())
def reject(self):
# повертаємо вигляд, який був до відкриття діалогу
self.preview_requested.emit(
self._start_values["theme"], self._start_values["font_size"]
)
super().reject()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Preferences demo")
self.settings = QSettings()
self.values = {
"theme": self.settings.value("ui/theme", "light", type=str),
"font_size": self.settings.value("ui/font_size", 12, type=int),
"autosave": self.settings.value("editor/autosave", True, type=bool),
"autosave_interval_s": self.settings.value(
"editor/autosave_interval_s", 30, type=int
),
}
self.editor = QTextEdit("Open Preferences from the File menu and try the settings.")
self.setCentralWidget(self.editor)
file_menu = self.menuBar().addMenu("&File")
preferences_action = file_menu.addAction("&Preferences...")
preferences_action.triggered.connect(self.open_preferences)
file_menu.addSeparator()
file_menu.addAction("E&xit", self.close)
self.apply_appearance(self.values["theme"], self.values["font_size"])
def open_preferences(self):
dialog = SettingsDialog(self.values, self)
dialog.preview_requested.connect(self.apply_appearance)
if dialog.exec() != QDialog.Accepted:
self.statusBar().showMessage("Preferences cancelled", 3000)
return
self.values = dialog.values()
self.settings.setValue("ui/theme", self.values["theme"])
self.settings.setValue("ui/font_size", self.values["font_size"])
self.settings.setValue("editor/autosave", self.values["autosave"])
self.settings.setValue("editor/autosave_interval_s", self.values["autosave_interval_s"])
self.settings.sync()
self.apply_appearance(self.values["theme"], self.values["font_size"])
self.statusBar().showMessage("Preferences saved", 3000)
def apply_appearance(self, theme, font_size):
if theme == "dark":
self.setStyleSheet("QTextEdit { background: #1e1e1e; color: #e0e0e0; }")
else:
self.setStyleSheet("QTextEdit { background: #ffffff; color: #202020; }")
font = self.editor.font()
font.setPointSize(font_size)
self.editor.setFont(font)
def main():
app = QApplication(sys.argv)
QCoreApplication.setOrganizationName("KTBP")
QCoreApplication.setApplicationName("PreferencesDemo")
window = MainWindow()
window.resize(560, 380)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Що тут показано:
- Діалог не має доступу ні до
QSettings, ні до головного вікна. Він отримує словник значень і віддає словник значень. Це той самий патерн «дані вниз конструктором, результат угору» з лекції 16. - Попередній перегляд іде сигналом. Діалог не викликає методів вікна — він надсилає
preview_requested, а вікно вирішує, що з цим робити. reject()перевизначено, щоб скасування повертало вигляд назад. Без цьогоCancelлишав би на екрані «приміряну» тему.- Залежні поля вимикаються.
interval_spinнеактивний, поки автозбереження вимкнене:toggled→setEnabledв один рядок. QSpinBoxзамістьQLineEdit. Ввести туди нечисло неможливо, діапазон гарантований віджетом — валідація безкоштовно.
Перезапуск потрібен не завжди
Повідомлення «зміни набудуть чинності після перезапуску» — ознака того, що налаштування читається один раз при старті й лежить у копії. Більшість налаштувань можна застосувати одразу, якщо вікно перемальовує себе за сигналом changed, а не запам'ятовує значення в момент створення.
Збірка: «Notes» із логом, обробкою помилок і налаштуваннями¶
Зберемо все докупи. Це той самий застосунок нотаток, що й у лекції 14, але тепер він:
- пише лог у файл із ротацією, включно з повідомленнями самого Qt;
- має глобальний перехоплювач, який показує вікно замість тихого traceback;
- зберігає дані атомарно з резервною копією й пропускає зіпсовані записи замість того, щоб втратити весь файл;
- показує помилки трьома різними способами залежно від ваги;
- пам'ятає геометрію вікна, розмір шрифту, автозбереження й підтвердження видалення;
- має діалог
Preferencesі пунктOpen log folder.
Структура:
notes/
├── main.py <- точка збірки
├── notes.json <- дані (створює застосунок)
└── app/
├── __init__.py
├── errors.py <- StorageError
├── models.py <- Note
├── storage.py <- JsonStorage: атомарний запис
├── settings.py <- AppSettings: обгортка QSettings
├── state.py <- AppState
├── logging_setup.py <- лог, хук Qt, sys.excepthook
└── ui/
├── __init__.py
├── message_bar.py
├── settings_dialog.py
└── main_window.py
Порівняно з лекцією 14 додалися три файли: errors.py, logging_setup.py і settings.py. Правило залежностей не змінилось — стрілки дивляться в один бік:
graph LR
MAIN["main.py"] --> LOG["app/logging_setup.py"]
MAIN --> UI["app/ui/*"]
MAIN --> SET["app/settings.py"]
MAIN --> STATE["app/state.py"]
MAIN --> STOR["app/storage.py"]
UI --> SET
UI --> STATE
STATE --> STOR
STATE --> ERR["app/errors.py"]
STATE --> MOD["app/models.py"]
STOR --> ERR
STOR --> MOD
Файли __init__.py порожні.
app/errors.py¶
app/models.py¶
from dataclasses import dataclass
@dataclass
class Note:
"""Одна нотатка. Про віджети й файли не знає нічого."""
id: int
title: str
body: str = ""
def to_dict(self):
return {"id": self.id, "title": self.title, "body": self.body}
@staticmethod
def from_dict(raw):
"""Створює нотатку з словника. Кидає ValueError на некоректних даних."""
if not isinstance(raw, dict):
raise ValueError(f"Note must be an object, got {type(raw).__name__}")
try:
note_id = int(raw["id"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError(f"Bad note id in {raw!r}") from error
title = raw.get("title", "")
body = raw.get("body", "")
if not isinstance(title, str) or not isinstance(body, str):
raise ValueError(f"Bad note text in {raw!r}")
return Note(id=note_id, title=title, body=body)
app/storage.py¶
import json
import logging
import os
from pathlib import Path
from .errors import StorageError
from .models import Note
logger = logging.getLogger(__name__)
class JsonStorage:
"""Читає й пише нотатки в JSON. Запис атомарний, попередня версія лишається."""
def __init__(self, path):
self._path = Path(path)
@property
def path(self):
return self._path
def load(self):
if not self._path.exists():
logger.info("No data file yet: %s", self._path)
return []
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except OSError as error:
raise StorageError(f"Cannot read {self._path}: {error}") from error
except json.JSONDecodeError as error:
raise StorageError(
f"File {self._path} is damaged (line {error.lineno})"
) from error
if not isinstance(raw, list):
raise StorageError(f"Unexpected format in {self._path}: a list is expected")
notes = []
for record in raw:
try:
notes.append(Note.from_dict(record))
except ValueError:
# один зіпсований запис не має коштувати всього файла
logger.warning("Skipping bad record: %r", record, exc_info=True)
logger.info("Loaded %s notes from %s", len(notes), self._path)
return notes
def save(self, notes):
text = json.dumps(
[note.to_dict() for note in notes], ensure_ascii=False, indent=2
)
temp_path = self._path.with_suffix(self._path.suffix + ".tmp")
backup_path = self._path.with_suffix(self._path.suffix + ".bak")
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
with open(temp_path, "w", encoding="utf-8") as file:
file.write(text)
file.flush()
os.fsync(file.fileno())
if self._path.exists():
os.replace(self._path, backup_path)
os.replace(temp_path, self._path)
except OSError as error:
raise StorageError(f"Cannot write {self._path}: {error}") from error
finally:
temp_path.unlink(missing_ok=True)
logger.info("Saved %s notes to %s", len(notes), self._path)
app/settings.py¶
import logging
from PySide6.QtCore import QByteArray, QObject, QSettings, Signal
logger = logging.getLogger(__name__)
class AppSettings(QObject):
"""Єдина точка доступу до QSettings: ключі, типи й межі живуть тут."""
changed = Signal()
def __init__(self, settings=None):
super().__init__()
self._settings = settings if settings is not None else QSettings()
logger.info("Settings file: %s", self._settings.fileName())
# --- налаштування користувача ------------------------------------------
@property
def font_size(self):
return min(max(self._settings.value("ui/font_size", 12, type=int), 8), 32)
@font_size.setter
def font_size(self, value):
self._settings.setValue("ui/font_size", int(value))
self.changed.emit()
@property
def autosave(self):
return self._settings.value("editor/autosave", True, type=bool)
@autosave.setter
def autosave(self, value):
self._settings.setValue("editor/autosave", bool(value))
self.changed.emit()
@property
def autosave_interval_s(self):
value = self._settings.value("editor/autosave_interval_s", 30, type=int)
return min(max(value, 5), 600)
@autosave_interval_s.setter
def autosave_interval_s(self, value):
self._settings.setValue("editor/autosave_interval_s", int(value))
self.changed.emit()
@property
def confirm_delete(self):
return self._settings.value("editor/confirm_delete", True, type=bool)
@confirm_delete.setter
def confirm_delete(self, value):
self._settings.setValue("editor/confirm_delete", bool(value))
self.changed.emit()
# --- стан інтерфейсу ----------------------------------------------------
def window_geometry(self):
return self._settings.value("window/geometry", QByteArray(), type=QByteArray)
def set_window_geometry(self, data):
self._settings.setValue("window/geometry", data)
def reset_window(self):
self._settings.remove("window")
def sync(self):
self._settings.sync()
app/state.py¶
import logging
from PySide6.QtCore import QObject, Signal
from .errors import StorageError
from .models import Note
logger = logging.getLogger(__name__)
class AppState(QObject):
"""Стан застосунку. Помилки сховища перетворює на сигнал error_occurred."""
notes_changed = Signal()
selection_changed = Signal(int)
dirty_changed = Signal(bool)
error_occurred = Signal(str)
def __init__(self, storage):
super().__init__()
self._storage = storage
self._notes = []
self._next_id = 1
self._selected_id = -1
self._dirty = False
# --- читання ------------------------------------------------------------
@property
def notes(self):
return list(self._notes)
@property
def selected_id(self):
return self._selected_id
@property
def dirty(self):
return self._dirty
def note_by_id(self, note_id):
for note in self._notes:
if note.id == note_id:
return note
return None
# --- зміни --------------------------------------------------------------
def load(self):
try:
self._notes = self._storage.load()
except StorageError as error:
logger.error("Load failed: %s", error)
self._notes = []
self.error_occurred.emit(str(error))
self._next_id = max((note.id for note in self._notes), default=0) + 1
self._set_dirty(False)
self.notes_changed.emit()
self.select(self._notes[0].id if self._notes else -1)
def save(self):
try:
self._storage.save(self._notes)
except StorageError as error:
logger.error("Save failed: %s", error)
self.error_occurred.emit(str(error))
return False
self._set_dirty(False)
return True
def add_note(self, title):
title = title.strip()
if not title:
self.error_occurred.emit("Note title must not be empty.")
return None
note = Note(id=self._next_id, title=title)
self._next_id += 1
self._notes.append(note)
self._set_dirty(True)
self.notes_changed.emit()
self.select(note.id)
return note
def remove_selected(self):
note = self.note_by_id(self._selected_id)
if note is None:
return False
self._notes.remove(note)
self._set_dirty(True)
self.notes_changed.emit()
self.select(self._notes[0].id if self._notes else -1)
return True
def update_body(self, note_id, body):
note = self.note_by_id(note_id)
if note is None or note.body == body:
return
note.body = body
self._set_dirty(True)
def select(self, note_id):
if note_id == self._selected_id:
return
self._selected_id = note_id
self.selection_changed.emit(note_id)
def _set_dirty(self, value):
if self._dirty == value:
return
self._dirty = value
self.dirty_changed.emit(value)
app/logging_setup.py¶
import logging
import sys
import traceback
from logging.handlers import RotatingFileHandler
from pathlib import Path
from PySide6.QtCore import QStandardPaths, QtMsgType, qInstallMessageHandler
from PySide6.QtWidgets import QMessageBox
_QT_LEVELS = {
QtMsgType.QtDebugMsg: logging.DEBUG,
QtMsgType.QtInfoMsg: logging.INFO,
QtMsgType.QtWarningMsg: logging.WARNING,
QtMsgType.QtCriticalMsg: logging.ERROR,
QtMsgType.QtFatalMsg: logging.CRITICAL,
}
logger = logging.getLogger(__name__)
def log_directory():
"""Каталог для лога. Викликати після setApplicationName()."""
base = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)
return Path(base) / "logs"
def setup_logging(level=logging.INFO):
"""Налаштовує кореневий логер. Повертає шлях до файла лога."""
directory = log_directory()
directory.mkdir(parents=True, exist_ok=True)
log_path = directory / "notes.log"
file_handler = RotatingFileHandler(
log_path, maxBytes=1_000_000, backupCount=3, encoding="utf-8"
)
file_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s: %(message)s")
)
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setFormatter(
logging.Formatter("%(levelname)-8s %(name)s: %(message)s")
)
root = logging.getLogger()
root.setLevel(level)
root.handlers.clear()
root.addHandler(file_handler)
root.addHandler(console_handler)
return log_path
def install_qt_message_handler():
"""Перенаправляє повідомлення самого Qt у logging."""
qt_logger = logging.getLogger("qt")
def handler(mode, context, message):
qt_logger.log(_QT_LEVELS.get(mode, logging.INFO), "%s", message)
qInstallMessageHandler(handler)
def install_excepthook(log_path):
"""Ловить усе, що ніхто не спіймав: пише в лог і показує вікно."""
state = {"busy": False}
def hook(exc_type, exc_value, exc_tb):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_tb)
return
logger.critical(
"Unhandled exception", exc_info=(exc_type, exc_value, exc_tb)
)
if state["busy"]:
return
state["busy"] = True
try:
details = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
box = QMessageBox()
box.setIcon(QMessageBox.Critical)
box.setWindowTitle("Notes")
box.setText("The application ran into an unexpected problem.")
box.setInformativeText(
f"{exc_type.__name__}: {exc_value}\n\nDetails are saved to:\n{log_path}"
)
box.setDetailedText(details)
box.setStandardButtons(QMessageBox.Close)
box.exec()
finally:
state["busy"] = False
sys.excepthook = hook
app/ui/message_bar.py¶
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QToolButton
class MessageBar(QFrame):
"""Ненав'язлива смужка з повідомленням над вмістом вікна."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet("background: #fdf0d5; border: 1px solid #d9a441;")
self.hide()
self._label = QLabel()
self._label.setWordWrap(True)
close_button = QToolButton()
close_button.setText("x")
close_button.clicked.connect(self.hide)
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 4, 4, 4)
layout.addWidget(self._label, 1)
layout.addWidget(close_button)
def show_message(self, text, timeout_ms=0):
self._label.setText(text)
self.show()
if timeout_ms > 0:
QTimer.singleShot(timeout_ms, self.hide)
app/ui/settings_dialog.py¶
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QSpinBox,
QVBoxLayout,
)
class SettingsDialog(QDialog):
"""Preferences. Про QSettings не знає: отримує й повертає словник."""
def __init__(self, values, parent=None):
super().__init__(parent)
self.setWindowTitle("Preferences")
self.setModal(True)
self.font_spin = QSpinBox()
self.font_spin.setRange(8, 32)
self.font_spin.setSuffix(" pt")
self.font_spin.setValue(values["font_size"])
self.autosave_check = QCheckBox("Save changes automatically")
self.autosave_check.setChecked(values["autosave"])
self.interval_spin = QSpinBox()
self.interval_spin.setRange(5, 600)
self.interval_spin.setSuffix(" s")
self.interval_spin.setValue(values["autosave_interval_s"])
self.interval_spin.setEnabled(values["autosave"])
self.confirm_check = QCheckBox("Ask before deleting a note")
self.confirm_check.setChecked(values["confirm_delete"])
self.autosave_check.toggled.connect(self.interval_spin.setEnabled)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form = QFormLayout()
form.addRow("Font size:", self.font_spin)
form.addRow("", self.autosave_check)
form.addRow("Autosave every:", self.interval_spin)
form.addRow("", self.confirm_check)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(QLabel("Settings are applied when you press OK."))
layout.addWidget(buttons)
def values(self):
return {
"font_size": self.font_spin.value(),
"autosave": self.autosave_check.isChecked(),
"autosave_interval_s": self.interval_spin.value(),
"confirm_delete": self.confirm_check.isChecked(),
}
app/ui/main_window.py¶
import logging
from PySide6.QtCore import Qt, QTimer, QUrl
from PySide6.QtGui import QDesktopServices, QKeySequence
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QInputDialog,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QTextEdit,
QVBoxLayout,
QWidget,
)
from .message_bar import MessageBar
from .settings_dialog import SettingsDialog
logger = logging.getLogger(__name__)
class MainWindow(QMainWindow):
"""Головне вікно: список нотаток ліворуч, редактор праворуч."""
def __init__(self, state, settings, log_path):
super().__init__()
self._state = state
self._settings = settings
self._log_path = log_path
self.setWindowTitle("Notes")
self.message_bar = MessageBar()
self.note_list = QListWidget()
self.note_list.currentItemChanged.connect(self.on_current_item_changed)
self.editor = QTextEdit()
self.editor.setEnabled(False)
self.editor.textChanged.connect(self.on_editor_changed)
content = QHBoxLayout()
content.addWidget(self.note_list, 1)
content.addWidget(self.editor, 2)
central = QWidget()
layout = QVBoxLayout(central)
layout.addWidget(self.message_bar)
layout.addLayout(content, 1)
self.setCentralWidget(central)
self._build_menu()
self._autosave_timer = QTimer(self)
self._autosave_timer.timeout.connect(self.on_autosave)
self._state.notes_changed.connect(self.render_notes)
self._state.selection_changed.connect(self.render_selection)
self._state.dirty_changed.connect(self.render_title)
self._state.error_occurred.connect(self.show_error)
self._settings.changed.connect(self.apply_settings)
self.apply_settings()
self.restore_geometry()
self.render_notes()
self.render_title(self._state.dirty)
# --- побудова -----------------------------------------------------------
def _build_menu(self):
file_menu = self.menuBar().addMenu("&File")
file_menu.addAction("&New note", QKeySequence.New, self.on_new_note)
file_menu.addAction("&Delete note", QKeySequence.Delete, self.on_delete_note)
file_menu.addSeparator()
file_menu.addAction("&Save", QKeySequence.Save, self.on_save)
file_menu.addSeparator()
file_menu.addAction("&Preferences...", self.on_preferences)
file_menu.addSeparator()
file_menu.addAction("E&xit", QKeySequence.Quit, self.close)
help_menu = self.menuBar().addMenu("&Help")
help_menu.addAction("Open &log folder", self.on_open_log_folder)
help_menu.addAction("&Reset window layout", self.on_reset_layout)
help_menu.addAction("Raise a test &error", self.on_test_error)
# --- рендер -------------------------------------------------------------
def render_notes(self):
self.note_list.blockSignals(True)
self.note_list.clear()
for note in self._state.notes:
item = QListWidgetItem(note.title)
item.setData(Qt.UserRole, note.id) # id ховаємо в елементі списку
self.note_list.addItem(item)
self.note_list.blockSignals(False)
self.render_selection(self._state.selected_id)
def render_selection(self, note_id):
note = self._state.note_by_id(note_id)
self.editor.blockSignals(True)
self.editor.setPlainText(note.body if note else "")
self.editor.setEnabled(note is not None)
self.editor.blockSignals(False)
for row in range(self.note_list.count()):
item = self.note_list.item(row)
if item.data(Qt.UserRole) == note_id:
self.note_list.setCurrentRow(row)
break
self.statusBar().showMessage(f"{len(self._state.notes)} notes")
def render_title(self, dirty):
self.setWindowTitle("Notes *" if dirty else "Notes")
def apply_settings(self):
font = self.editor.font()
font.setPointSize(self._settings.font_size)
self.editor.setFont(font)
if self._settings.autosave:
self._autosave_timer.start(self._settings.autosave_interval_s * 1000)
else:
self._autosave_timer.stop()
logger.debug(
"Settings applied: font=%s autosave=%s interval=%ss",
self._settings.font_size,
self._settings.autosave,
self._settings.autosave_interval_s,
)
# --- дії ----------------------------------------------------------------
def on_current_item_changed(self, current, previous):
if current is not None:
self._state.select(current.data(Qt.UserRole))
def on_editor_changed(self):
self._state.update_body(self._state.selected_id, self.editor.toPlainText())
def on_new_note(self):
title, accepted = QInputDialog.getText(self, "New note", "Title:")
if not accepted:
return
self._state.add_note(title)
def on_delete_note(self):
note = self._state.note_by_id(self._state.selected_id)
if note is None:
self.message_bar.show_message("Select a note first.", 4000)
return
if self._settings.confirm_delete:
answer = QMessageBox.question(
self,
"Notes",
f'Delete "{note.title}"?',
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
return
self._state.remove_selected()
def on_save(self):
if self._state.save():
self.statusBar().showMessage("Saved", 3000)
def on_autosave(self):
if not self._state.dirty:
return
logger.debug("Autosave tick")
if not self._state.save():
self._autosave_timer.stop() # не смикаємо диск раз на секунду
self.message_bar.show_message(
"Autosave is turned off after a failure. Fix the problem and save manually."
)
def on_preferences(self):
values = {
"font_size": self._settings.font_size,
"autosave": self._settings.autosave,
"autosave_interval_s": self._settings.autosave_interval_s,
"confirm_delete": self._settings.confirm_delete,
}
dialog = SettingsDialog(values, self)
if dialog.exec() != QDialog.Accepted:
return
new_values = dialog.values()
self._settings.font_size = new_values["font_size"]
self._settings.autosave = new_values["autosave"]
self._settings.autosave_interval_s = new_values["autosave_interval_s"]
self._settings.confirm_delete = new_values["confirm_delete"]
self.statusBar().showMessage("Preferences saved", 3000)
def on_open_log_folder(self):
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._log_path.parent)))
def on_reset_layout(self):
self._settings.reset_window()
QMessageBox.information(
self, "Notes", "Window layout will be reset on the next start."
)
def on_test_error(self):
notes = {}
return notes["no such key"] # навмисний KeyError для перевірки
# --- помилки ------------------------------------------------------------
def show_error(self, message):
logger.error("Reported to user: %s", message)
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("Notes")
box.setText(message)
box.setInformativeText("Your notes are still open. Nothing is lost yet.")
box.setDetailedText(f"See the log file:\n{self._log_path}")
box.setStandardButtons(QMessageBox.Ok)
box.exec()
# --- геометрія й закриття ------------------------------------------------
def restore_geometry(self):
geometry = self._settings.window_geometry()
if geometry.isEmpty():
self.resize(860, 520)
else:
self.restoreGeometry(geometry)
def closeEvent(self, event):
if self._state.dirty:
answer = QMessageBox.question(
self,
"Notes",
"You have unsaved changes. Save before closing?",
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
QMessageBox.Save,
)
if answer == QMessageBox.Cancel:
event.ignore()
return
if answer == QMessageBox.Save and not self._state.save():
event.ignore() # збереження не вдалося — не закриваємось
return
self._settings.set_window_geometry(self.saveGeometry())
self._settings.sync()
logger.info("Application closed")
super().closeEvent(event)
main.py¶
import logging
import sys
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication
from app.logging_setup import install_excepthook, install_qt_message_handler, setup_logging
from app.settings import AppSettings
from app.state import AppState
from app.storage import JsonStorage
from app.ui.main_window import MainWindow
DATA_FILE = "notes.json"
def main():
app = QApplication(sys.argv)
QCoreApplication.setOrganizationName("KTBP")
QCoreApplication.setApplicationName("Notes")
log_path = setup_logging(logging.DEBUG)
install_qt_message_handler()
install_excepthook(log_path)
logger = logging.getLogger(__name__)
logger.info("Application started, log file: %s", log_path)
settings = AppSettings()
storage = JsonStorage(DATA_FILE)
state = AppState(storage)
window = MainWindow(state, settings, log_path)
window.show()
state.load()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Що варто побачити в цьому коді¶
- Жоден
exceptне порожній. Кожен або перекладає виняток у свій тип, або пише в лог, або повідомляє користувача — а частіше все разом. - Три канали для помилок. Порожня назва нотатки —
error_occurred→ діалог; невдале автозбереження — смужка й вимкнення таймера; зіпсований запис у файлі — тількиWARNINGу лозі. closeEventне закриває вікно, якщо збереження не вдалося.event.ignore()тут єдиний спосіб не втратити дані користувача мовчки.- Автозбереження вимикається після першої ж помилки. Інакше застосунок довбатиме недоступний диск раз на тридцять секунд і показуватиме нескінченну низку повідомлень.
AppSettings— єдине місце, де є рядок"ui/font_size". Решта коду пишеsettings.font_size.MainWindowотримуєstate,settingsіlog_pathконструктором. Він нічого не створює сам, тому його можна запустити з тимчасовимQSettingsі не зіпсувати справжні налаштування.SettingsDialogне знає ані проQSettings, ані про головне вікно. Словник усередину, словник назовні.Note.from_dictперевіряє дані. Файл на диску — зовнішні дані, і довіряти їм не можна.
Що перевірити руками¶
- Звичайний прохід. Запустити, створити дві нотатки, написати текст,
Ctrl+S. З'явивсяnotes.json. Закрити й запустити знову — усе на місці, вікно того самого розміру. - Зірочка в заголовку. Змінити текст — у заголовку
Notes *. Зберегти — зірочка зникла. - Закриття з незбереженими змінами. Змінити текст і закрити вікно: діалог
Save / Discard / Cancel.Cancelлишає вікно відкритим. - Пошкоджений файл даних. Записати в
notes.jsonтекст{ broken, запустити застосунок. З'явиться діалогFile notes.json is damaged (line 1), застосунок відкриється порожнім і робочим, у лозі будеERRORз повним traceback і рядкомThe above exception was the direct cause. - Частково зіпсований файл. Записати
[{"id": 1, "title": "ok"}, {"id": "x"}, "junk"]. Застосунок покаже одну нотатку, а в лозі буде дваWARNING: Skipping bad record. Жодного діалогу — користувач не постраждав. - Неможливий запис. Змінити
DATA_FILEуmain.pyна/notes.json(кореневий каталог), створити нотатку,Ctrl+S. ДіалогCannot write /notes.json: [Errno 13] Permission denied, застосунок працює далі, дані в пам'яті цілі. - Резервна копія. Зберегти двічі з різним текстом і подивитись на
notes.json.bak— там попередня версія. - Глобальний перехоплювач.
Help → Raise a test error. Вікно зKeyError, кнопкаShow Details...із traceback, застосунок не закривається. У лозі —CRITICAL Unhandled exception. - Лог.
Help → Open log folderвідкриває каталог у файловому менеджері. Файлnotes.logмістить і рядки застосунку, і рядки з логераqt. - Налаштування.
File → Preferences, змінити розмір шрифту — шрифт редактора змінюється одразу післяOK. Перезапустити — розмір збережено. Вимкнути автозбереження — поле інтервалу стає неактивним. - Автозбереження. Виставити інтервал 5 секунд, змінити текст, зачекати — зірочка зникає сама, у лозі
Autosave tick. - Скидання розкладки. Розтягнути вікно,
Help → Reset window layout, перезапустити — вікно знову 860×520.
Типові помилки¶
1. except Exception: pass
Дані не збережено, користувач упевнений, що збережено. Найдорожчий рядок у програмуванні.
2. Ловити те, чого не можна виправити в цьому місці
IndexError тут означає, що список і представлення розійшлися. Мовчазний return перетворює помітну помилку на невідтворювану.
3. Голий except:
Ловить KeyboardInterrupt і SystemExit, тобто заважає застосунку завершитись. Завжди except Exception.
4. print() замість лога
Запакований застосунок (лекція 24) часто не має консолі. Усе, що ви надрукували, зникає.
5. Traceback у setText() діалогу
Користувач бачить сорок рядків, з яких нічого не зрозуміло. Traceback — у setDetailedText() і в лог.
6. QMessageBox у циклі
for path in paths:
try:
copy(path)
except OSError as error:
QMessageBox.warning(self, "Error", str(error)) # ПОМИЛКА
П'ятсот файлів — п'ятсот діалогів, і застосунок неможливо закрити. Збирайте помилки в список і покажіть один підсумок.
7. QMessageBox без батька
QMessageBox.warning(None, ...) може відкритись за головним вікном або на іншому моніторі. Перший аргумент — self.
8. Кнопка лишається вимкненою після помилки
Відновлення інтерфейсу має бути у finally (або в слоті thread.finished), а не після успішної гілки.
9. Виняток у воркері без try
Воркер помирає, finished не приходить, вікно назавжди в стані Working.... run() завжди обгортають try і мають сигнал failed.
10. write_text для важливих даних
Файл спочатку обрізається до нуля. Збій посеред запису — і даних немає. Пишуть у .tmp, потім os.replace.
11. settings.value("editor/autosave") без type=bool
Повертається рядок 'false', який у if істинний. Вимкнене налаштування працює як увімкнене.
12. settings.value("ui/font_size", 12) без type=int
Повертається '12', і перша ж арифметика дає TypeError. Типове значення тип не перетворює.
13. Список з одного елемента з QSettings
['only.json'] при читанні стає 'only.json', і цикл біжить по літерах. Використовуйте type=list, масиви (beginWriteArray) або JSON-рядок.
14. QSettings() розсипаний по всьому коду
Ключі-рядки дублюються, типові значення розходяться, тестувати неможливо. Один клас-обгортка, решта отримує його залежністю.
15. Дані в QSettings
Список нотаток — не налаштування. Немає ані транзакцій, ані резервних копій.
16. Пошкоджений файл налаштувань валить запуск
Дані втрачати не можна, налаштування — можна. Зіпсований конфіг — це WARNING і типові значення, а не аварійне завершення.
17. Забутий endGroup
Усі наступні ключі отримують чужий префікс. Використовуйте менеджер контексту.
18. saveState() без objectName
QMainWindow::saveState(): 'objectName' not set — розкладка не відновиться. Кожній панелі й доку потрібен setObjectName().
19. Чотири числа замість saveGeometry()
Не переживає розгортання вікна й від'єднання монітора; вікно може відкритись за межами екрана.
20. Немає перевірки на перший запуск
restoreGeometry(QByteArray()) нічого не робить, і вікно отримує випадковий розмір. Потрібна гілка else з явним resize().
21. Налаштування без меж
font_size=900 із відредагованого руками файла — і вікно непридатне. Налаштування — зовнішні дані, їх перевіряють як введення користувача.
22. setOrganizationName забуто
QSettings і QStandardPaths дають безіменні шляхи; налаштування «зникають» між запусками або лежать не там, де ви шукаєте.
23. Лог у робочому каталозі
logging.FileHandler("app.log") пише туди, звідки запустили програму. У запакованому застосунку цей каталог часто доступний лише для читання. Місце дає QStandardPaths.
24. Лог без ротації
FileHandler росте необмежено. RotatingFileHandler із maxBytes і backupCount.
25. logger.error("Save failed") без exc_info
У лозі є факт помилки, але немає причини. Усередині except пишуть logger.exception(...).
26. Автозбереження, яке не вимикається після помилки
Недоступний диск + таймер = нескінченна низка діалогів. Після невдачі таймер зупиняють і повідомляють користувача один раз.
27. Об'єкт винятку, переданий сигналом між потоками
Виняток тягне traceback, traceback тримає кадри стека чужого потоку. Передавайте рядок.
28. Модальний діалог із sys.excepthook без захисту
Помилка при малюванні → діалог → перемальовування → помилка → діалог. Потрібен прапорець «діалог уже показано».
Підсумок¶
- Помилки бувають трьох родів, і поводяться з ними по-різному: баг програміста — у лог і глобальний хук; збій середовища — точковий
try; некоректне введення — перевірка до дії. - Виняток у слоті не закриває застосунок. Він друкує traceback у консоль, якої користувач не бачить, і лишає слот обірваним посередині — з вимкненими кнопками й недоробленою дією.
- Власний тип винятку — це межа шару.
storage.pyперекладаєOSErrorіJSONDecodeErrorуStorageError; вікно не має знати, що всередині JSON. - Ловіть там, де є що зробити: підставити план Б, повідомити користувача, перекласти у свій тип. Якщо жодне з трьох неможливе — не ловіть.
sys.excepthook— останній рубіж, а не заміна точковій обробці. Він працює лише в головному потоці: у воркеріrun()завжди обгортаютьtryі мають сигналfailed.- Лог — для розробника, повідомлення — для користувача.
loggingзамістьprint, логер на модуль,RotatingFileHandlerу каталозі відQStandardPaths,exc_info=Trueдля traceback іqInstallMessageHandlerдля повідомлень самого Qt. - Спосіб повідомлення обирають за вагою: рядок стану для дрібниць, смужка для помилок у контексті,
QMessageBoxдля того, чого користувач чекав. Одна помилка — одне повідомлення, ніколи не в циклі. - Хороше повідомлення відповідає на три питання: що не вдалося, чому, що робити. Технічні подробиці — у
setDetailedText()і в лог. - Найдешевша помилка — та, якої не сталося. Валідатори,
QSpinBox, перевірка перед дією й вимкнена кнопка знімають більшу частинуtry. - Дані зберігають атомарно: запис у
.tmp,fsync,os.replace, попередня версія в.bak.write_textспочатку обрізає файл до нуля. - Налаштування — не дані. Втрата даних — катастрофа, втрата налаштувань — незручність. Пошкоджений конфіг означає типові значення й
WARNING, а не відмову запускатись. QSettingsховає відмінності платформ, але повертає рядки:type=int,type=bool— обов'язкові, а'false'без них істинний.- Геометрію вікна зберігають через
saveGeometry()/saveState(), а не через чотири числа; панелям і докам потрібніobjectName, а першому запуску — гілкаelseзresize(). - Налаштування читає один клас.
AppSettingsтримає ключі, типи, межі й сигналchanged; решта коду пишеsettings.font_sizeі не знає ні про рядки-ключі, ні проQSettings.
Корисні посилання¶
- Python:
logging - Python: Logging HOWTO
- Python:
logging.handlers - Python: вбудовані винятки
- Python:
sys.excepthook - Python:
traceback - Python:
os.replace - Python:
tomllib - QSettings
- QStandardPaths
- QMessageBox
- QDialogButtonBox
- QValidator
- QMainWindow:
saveState - QWidget:
saveGeometry - qInstallMessageHandler
- QDesktopServices
Домашнє завдання¶
Зібрати застосунок «Notes» із розділу «Збірка» й пройти всі дванадцять перевірок із «Що перевірити руками». Далі доопрацювати його так, щоб:
- у меню
Fileз'явився підпунктOpen recentзі списком останніх п'яти файлів даних; список зберігати черезbeginWriteArray(не простим списком — перевірте, що один елемент читається правильно) і оновлювати при кожному успішному завантаженні; - у меню
Helpз'явився пунктShow last error, який відкриває вікно з останніми 50 рядками файла лога; читання лога теж обгорнутиtry— файл може бути заблокований або відсутній; - при пошкодженому
notes.jsonзастосунок пропонував діалог із трьома кнопками:Restore from backup(прочитатиnotes.json.bak),Start emptyіQuit; вибір користувача записувати в лог; - у
Preferencesдодалось полеLog level(INFO/DEBUG) і рівень застосовувався одразу, без перезапуску:logging.getLogger().setLevel(...)у слотіchanged; AppSettingsотримав властивістьdata_path(шлях до файла нотаток) і пункт менюFile → Change data folder...черезQFileDialog.getExistingDirectory(); після зміни застосунок перечитує дані з нового місця й повідомляє про це в рядку стану.
Знайшли помилку чи бажаєте додати інформацію, щоб покращити курс? Створіть issue на GitHub