
Kawai.Focus - приложение для фокусировки внимания (часть 4)
Данная статья посвящена:
- Работе с фреймворком Kivy в проекте Kawai.Focus;
- Улучшению кода для
ReadJson
; - Написанию тестов Pytest;
- Созданию валидатора для
custom_timer()
.
Дополнительные материалы

Для скачивания материалов необходимо войти или зарегистрироваться
Файлы также можно получить в Telegram-боте по коду: 440242
Реклама

Вступление
Всем доброго дня! В предыдущей статье Kawai.Focus - приложение для фокусировки внимания (часть 3) я:
- Написал код для работы с JSON;
- Улучшил и оптимизировал код функции
custom_timer()
; - Перенёс код интерфейса UI из
timer_screen.py
в.kv
файл; - Подключил звук для таймера и сделал ещё несколько небольших изменений.
Изначально я планировал посвятить эту статью продолжению работы над функционалом таймера Pomodoro. Однако в процессе получилось столько улучшений кода, что они заняли целую статью. Поэтому я решил полностью сосредоточиться на доработке текущего кода.
В следующей статье я уже перейду к созданию экрана “Конструктора” таймера и подключу базу SQLite3 для хранения данных. А чтобы эта статья была ещё полезнее, я добавлю несколько тестов с использованием библиотеки Pytest.
Заваривайте чай, доставайте вкусняшки — пора делать “помидор” ещё вкуснее! 🍅
Улучшение кода для чтения JSON
Старый data_json.py
Содержимое файла data_json.py
:
import json
from kawai_focus.main import Logger
class ReadJson:
"""Класс для чтения json файла"""
def __init__(self, file_name: str):
self.file_name = file_name
self.json_data = self.read_json_as_dict()
def read_json_as_dict(self) -> dict:
"""Метод для чтения json файла как dict"""
try:
file_path = f'json/{self.file_name}'
with open(file=file_path, mode='r') as file:
return json.load(file)
except (FileNotFoundError, json.decoder.JSONDecodeError) as err:
Logger.error(f'Logger: {err.__class__.__name__}: {err}')
def get_text(self, name: str) -> str:
"""Метод для возврата текста из файла json по ключу name"""
text = self.json_data.get(name)
if text:
if isinstance(text, list):
text = ''.join(text)
return text
return text
read_json_err = ReadJson(file_name='errors.json')
read_json_timer = ReadJson(file_name='timer.json')
Необходимые улучшения для кода:
- Перенос кода из
data_json.py
вutils.py
: когда кода мало создание отдельного файла лишняя работа; - Сделать чтение JSON универсальным: вместо того, чтобы постоянно создавать новый экземпляр класса для каждого JSON можно обойти папку
json
с файлами с расширением.json
и содержимое всех файлов положить в одинdict
; - Создавать экземпляр класса для чтения JSON в
settings.py
: я создам файлsettings.py
для хранения настроек базы данных, прочитанных данных JSON и т.д; - Перенести содержимое
errors.json
в классErrorMessage
, создав его в файлеerrors.py
. Забегая вперёд, отмечу, что мне потребовалось разместить строки для ошибок в коде, чтобы улучшить его структуру и повысить отказоустойчивость. КлассReadJson
не может читать строки ошибок для самого себя, поэтому возникла необходимость перенести строки ошибок в отдельный класс. Сам перенос не касается кодаReadJson
, но коснётся вызова строк ошибок в нём.
errors.py
Содержимое файла errors.py
:
from enum import Enum
class ErrorMessage(Enum):
NO_TIME = 'Не указано время таймера!'
NEGATIVE_TIME = 'Время таймера не может быть отрицательным!'
SS_MM_BIG = 'Секунды и минуты не могут быть больше 59!'
HH_BIG = 'Часы не могут быть больше 23!'
FOLDER_NOT_FOUND = 'Папка не найдена!'
JSON_NOT_FOUND = 'Файл json не найден!'
TYPE_DICT = 'Тип данных не является словарем!'
NOT_INT_TYPE = 'Тип данных не является целым числом!'
Пояснения к коду errors.py
:
from enum import Enum
— импорт классаEnum
, который предоставляет способ создания групп связанных констант с уникальными именами;ErrorMessage
— класс для хранения фиксированных сообщений об ошибках;NO_TIME
и другие константы — хранят сообщение об ошибках.
Улучшенный класс ReadJson
Код для ReadJson
:
import json
from os import path, listdir
from kawai_focus.main import Logger
from kawai_focus.utils.errors import ErrorMessage
class ReadJson:
"""Класс для чтения всех JSON-файлов в директории и объединения их в словарь."""
def __init__(self, folder_json: str):
self.folder_json = folder_json
self._json_data = self._read_all_json()
def _read_all_json(self) -> dict:
"""Читает все JSON-файлы в директории и возвращает объединённый словарь."""
if not path.exists(self.folder_json):
raise FileNotFoundError(ErrorMessage.FOLDER_NOT_FOUND.value)
# Получаем список всех файлов с расширением .json
json_files = [
path.join(self.folder_json, file)
for file in listdir(self.folder_json)
if file.endswith('.json')
]
if not json_files:
raise FileNotFoundError(ErrorMessage.JSON_NOT_FOUND.value)
data_dict = {}
for file_path in json_files:
with open(file_path, 'r', encoding='utf-8') as file:
# Обновляем словарь с помощью .update() для эффективности
data_dict.update(json.load(file))
return data_dict
def get_text(self, name: str) -> str:
"""Возвращает текст из словаря по ключу."""
return self._json_data.get(name, ErrorMessage.KEY_ERROR.value)
data_json = ReadJson(folder_json='json')
Пояснения по обновлённому коду ReadJson
:
Чтение JSON из директории:
- Вместо обработки одного файла теперь читаются все JSON-файлы из указанной директории
folder_json
; - Используется
os.listdir()
для получения списка файлов в папке;
Фильтрация файлов:
- С помощью list comprehension создаётся список только тех файлов, которые имеют расширение
.json
;
Использование метода .update()
:
- При объединении данных из всех JSON-файлов теперь используется метод
dict.update()
для эффективности и читаемости;
Добавлена обработка отсутствующей директории и файлов:
- Если директория не существует, выбрасывается исключение
FileNotFoundError
с сообщением изErrorMessage.FOLDER_NOT_FOUND.value
; - Если JSON-файлы не найдены, выбрасывается исключение с сообщением из
ErrorMessage.JSON_NOT_FOUND.value
;
Объединение всех данных:
- Все JSON-файлы в директории объединяются в единый словарь
data_dict
;
Использование Enum для сообщений об ошибках:
- Класс
ErrorMessage
из библиотекиkawai_focus.utils.errors
используется для управления текстами сообщений об ошибках; - Это делает код более структурированным и удобным для поддержки;
Ключ по умолчанию в get_text
:
- Если ключ не найден в объединённом словаре, возвращается значение из
ErrorMessage.KEY_ERROR.value
.
Теперь код в классе ReadJson
универсальным и может читать неограниченное количество JSON файлов.
Улучшение custom_timer()
Старый custom_timer()
В прошлый раз я уже доработал функцию custom_timer()
, но упустил важную деталь — я не вынес проверку значений в отдельный валидатор. Сейчас там целых четыре if-условия
подряд, и всё это превращается в небольшую портянку, которая ухудшает читаемость.
Пока кода немного — это терпимо, но по мере роста проекта такие конструкции начнут мешать. Код будет разрастаться по вертикали, станет сложнее ориентироваться и придётся больше скроллить, чтобы добраться до сути. Поэтому следующим шагом будет вынос логики валидации в отдельную функцию — так и чище, и удобнее для будущих изменений.
Содержимое старого custom_timer()
:
from typing import Generator
from kawai_focus.main import Logger
from kawai_focus.utils.data_json import read_json_err
def custom_timer(hh: int=0, mm: int=0, ss: int=0) -> Generator[str, None, None]:
"""Функция отсчитывает время, установленное для таймера в формате
'hh:mm:ss'. Возвращает генератор, который возвращает текущее время
в формате 'hh:mm:ss'."""
try:
if ss 0 and mm 0 and hh 0:
raise ValueError(read_json_err.get_text('no_time'))
if ss < 0 or mm < 0 or hh < 0:
raise ValueError(read_json_err.get_text('negative_time'))
if ss > 59 or mm > 59:
raise ValueError(read_json_err.get_text('ss_mm_big'))
if hh > 23:
raise ValueError(read_json_err.get_text('hh_big'))
total_seconds = (int(hh or 0) * 3600) + (int(mm or 0) * 60) + (int(ss or 0))
for remaining in range(total_seconds, -1, -1):
yield f"{remaining // 3600:02d}:{(remaining % 3600) // 60:02d}:{remaining % 60:02d}"
except (TypeError, ValueError) as err:
Logger.error(f'Logger: {err.__class__.__name__}: {err}')
validators.py
Для того чтобы убрать условия проверки из функции custom_timer()
я создал файл validators.py
, в котором будут хранится валидаторы. Я использовал валидаторы для веб-фреймворков таких как Django и других. Стыдно признаться, но я никогда не писал отдельный валидатор на чистом Python. Мне подсказал друг, что для валидации хорошо подойдут dataclasses
.
Для чего нужны dataclasses
:
- Позволяют автоматически генерировать методы (например,
__init__
,__repr__
,__eq__
) для классов, которые используются преимущественно для хранения данных (что удобно и экономит строки кода); - Подходят для валидации и работы с типами данных;
- Можно валидировать типы данных с использованием
pydantic
(не использую из-за избыточности функционала).
Содержимое validators.py
:
from dataclasses import dataclass
from kawai_focus.utils.errors import ErrorMessage
@dataclass
class TimerValidator:
"""Класс для валидации функции таймера."""
hh: int = 0
mm: int = 0
ss: int = 0
def __post_init__(self):
if not all(isinstance(value, int) for value in (self.hh, self.mm, self.ss)):
raise TypeError(ErrorMessage.NOT_INT_TYPE.value)
if self.ss 0 and self.mm 0 and self.hh 0:
raise ValueError(ErrorMessage.NO_TIME.value)
if self.ss < 0 or self.mm < 0 or self.hh < 0:
raise ValueError(ErrorMessage.NEGATIVE_TIME.value)
if self.ss > 59 or self.mm > 59:
raise ValueError(ErrorMessage.SS_MM_BIG.value)
if self.hh > 23:
raise ValueError(ErrorMessage.HH_BIG.value)
Пояснения по коду validators.py
:
from dataclasses import dataclass
— импорт декоратора@dataclass
, который позволяет автоматически генерировать методы для класса;from kawai_focus.utils.errors import error_message
— импорт экземпляра класса с ошибками;class TimerValidator:
— класс для валидации функцииcustom_timer()
;hh: int = 0
,mm: int = 0
,ss: int = 0
— атрибуты класса для часов, минут и секунд, которые я перенёс из функцииcustom_timer()
(были там в роли параметров);def __post_init__(self):
— используется в качестве дополнительной логики после инициализации (в данном случае подойдёт для валидации);if not all(isinstance(value, int) for value in (self.hh, self.mm, self.ss)):
— условие сработает если хотя-бы одно значение из кортежа не будетint
типа;raise TypeError(ErrorMessage.NOT_INT_TYPE.value)
— вызовет ошибкуTypeError
с кастомной строкой ошибки из константыNOT_INT_TYPE
;- В остальной валидации изменился лишь способ получения строк ошибок — теперь везде используются константы из класса
ErrorMessage
.
Улучшенная функция custom_timer()
# Другой код
from kawai_focus.utils.validators import TimerValidator
def custom_timer(valid_data: TimerValidator) -> Generator[str, None, None]:
"""Функция отсчитывает время, установленное для таймера в формате
'hh:mm:ss'. Возвращает генератор, который возвращает текущее время
в формате 'hh:mm:ss'."""
try:
total_seconds = (int(valid_data.hh) * 3600) + (int(valid_data.mm) * 60) + (int(valid_data.ss))
for remaining in range(total_seconds, -1, -1):
yield f"{remaining // 3600:02d}:{(remaining % 3600) // 60:02d}:{remaining % 60:02d}"
except (TypeError, ValueError) as err:
Logger.error(f'Logger: {err.__class__.__name__}: {err}')
Пояснения по обновлённому коду custom_timer()
:
from kawai_focus.utils.validators import TimerValidator
— импорт валидатораTimerValidator
;def custom_timer(valid_data: TimerValidator) -> Generator[str, None, None]:
— функция теперь принимаетTimerValidator
с данными таймера в качестве аргумента.
На мой взгляд код выглядит гораздо более лаконичным после того как валидация данных была перенесена в validators.py
.
Внедрение TimerValidator
в класс TimerScreen
Осталось внедрить TimerValidator
в экран таймера, который находится в классе TimerScreen
.
Обновлённый код для TimerScreen
:
# Другой код
from kawai_focus.utils.validators import TimerValidator
# Другой код
# Инициализация генератора таймера
vaid_timer_data = TimerValidator(ss=12) # Установите необходимое время
self.timer_generator = custom_timer(valid_data=vaid_timer_data)
# Другой код
Тесты
После написания TimerValidator
я решил проверить его работу. Обычно я проверяю код вручную, но писать тесты — это база, поэтому решил: почему бы и нет? Для тестирования я выбрал библиотеку pytest
, так как она проста в использовании и подходит для простых тестов.
Установка pytest и зависимостей:
poetry add pytest
poetry add typing_extension
typing_extensions
— это библиотека в Python, которая предоставляет типы и функции для аннотации типов.
validators_tests.py
Содержимое validators_tests.py
:
import pytest
from kawai_focus.utils.errors import ErrorMessage
from kawai_focus.utils.validators import TimerValidator
def test_valid_time():
"""Тест корректных данных"""
timer = TimerValidator(hh=10, mm=30, ss=15)
assert timer.hh 10
assert timer.mm 30
assert timer.ss 15
def test_not_int_type():
"""Тест исключения для некорректного типа данных"""
with pytest.raises(TypeError) as excinfo:
TimerValidator(hh="10", mm=30, ss=15)
assert str(excinfo.value) ErrorMessage.NOT_INT_TYPE.value
def test_no_time():
"""Тест исключения ситуации когда не указано время"""
with pytest.raises(ValueError) as excinfo:
TimerValidator(hh=0, mm=0, ss=0)
assert str(excinfo.value) ErrorMessage.NO_TIME.value
def test_negative_time():
"""Тест исключения для отрицательного времени"""
with pytest.raises(ValueError) as excinfo:
TimerValidator(hh=-1, mm=30, ss=15)
assert str(excinfo.value) ErrorMessage.NEGATIVE_TIME.value
def test_exceed_seconds_or_minutes():
"""Тест исключения для секунд/минут больше 59"""
with pytest.raises(ValueError) as excinfo:
TimerValidator(hh=0, mm=60, ss=10)
assert str(excinfo.value) ErrorMessage.SS_MM_BIG.value
def test_exceed_hours():
"""Тест исключения для часов больше 23"""
with pytest.raises(ValueError) as excinfo:
TimerValidator(hh=24, mm=0, ss=0)
assert str(excinfo.value) ErrorMessage.HH_BIG.value
Разберём код теста на примере test_not_int_type()
:
- Назначение теста — проверка если тип данных не
int
; with pytest.raises(TypeError) as excinfo:
— контекстный менеджер ожидает, что код внутри блока вызовет исключениеTypeError
;TimerValidator(hh="10", mm=30, ss=15)
— кладём значения для таймера, одно из которых с типом данныхstr
(неправильное значение для валидатора);assert str(excinfo.value) ErrorMessage.NOT_INT_TYPE.value
— проверкаassert
будет пройдена если строки ошибок будут равны.
Тесты хорошо подписаны и логически схожи между собой, поэтому нет смысла подробно разбирать каждый из них — общая структура понятна и не требует пояснений.
Запуск тестов validators_tests.py
:
pytest validators_tests.py
Вывод результата в terminal:
= test session starts
platform linux -- Python 3.12.0, pytest-8.3.5, pluggy-1.5.0
rootdir: /media/user_name/Files/Programming/Python/Projects/kawai-focus
configfile: pyproject.toml
plugins: anyio-4.8.0
collected 6 items
validators_tests.py ...... [100%]
6 passed in 0.15s =
Информация 6 passed
говорит о том, что 6 тестов завершены успешно. Если какой-то тест пройден не будет, то появится failed
с количеством не пройденных тестов и появится AssertionError
с информацией об ошибке.
Все тесты пройдены успешно.
Анонс на следующие статьи
Следующая моя статья выйдет 8 мая 2025 года. В ней я добавлю экран конструктора таймера с возможностью переключения между экранами. Также появится настройка длительности и других параметров таймера. Для их хранения я подключу базу данных SQLite3 и начну сохранять пользовательские настройки.
Всё, что изначально планировалось для этой статьи, переезжает в следующую — и поверьте, там будет интересно!
Читайте продолжение — не пропустите!
Заключение
- Улучшен код
ReadJson
(читалка JSON); - Написан класс для сообщений ошибок
ErrorMessage
на основеEnum
; - Написан валидатор для таймера
TimerValidator
на основеdataclasses
; - Улучшена функция
custom_timer()
; - Написаны тесты для
validators_tests.py
.
Ссылки к статье
- Мои статьи Arduinum628 на Код на салфетке;
- Репозиторий проекта Kawai.Focus.
Все статьи