Cat

Kawai.Focus - приложение для фокусировки внимания (часть 4)

Данная статья посвящена:

  • Работе с фреймворком Kivy в проекте Kawai.Focus;
  • Улучшению кода для ReadJson;
  • Написанию тестов Pytest;
  • Созданию валидатора для custom_timer().
Kawai.Focus Arduinum628 16 Апрель 2025 Просмотров: 28

Вступление

Всем доброго дня! В предыдущей статье 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 и начну сохранять пользовательские настройки.

Всё, что изначально планировалось для этой статьи, переезжает в следующую — и поверьте, там будет интересно!

Читайте продолжение — не пропустите!


Заключение

  1. Улучшен код ReadJson (читалка JSON);
  2. Написан класс для сообщений ошибок ErrorMessage на основе Enum;
  3. Написан валидатор для таймера TimerValidator на основе dataclasses;
  4. Улучшена функция custom_timer();
  5. Написаны тесты для validators_tests.py.

Ссылки к статье

Автор

    Нет комментариев

    Реклама