
FastAPI 4. Модель пользователя, миксины и Alembic
В этом посте начнём работу по системе пользователей в проекте. Опишем модели базы данных и инициализируем Alembic для создания миграций.
Реклама

Практически в каждом проекте требуется модель пользователя — для регистрации, авторизации и работы с профилем. Наш проект не станет исключением.
Изначально я планировал использовать готовую библиотеку FastAPI Users
. Такие решения действительно могут значительно упростить разработку. Однако они не всегда позволяют глубже понять, как работают ключевые механизмы фреймворка. Более того, у готовых библиотек могут возникать следующие проблемы:
- Совместимость: Задержки в поддержке новых версий FastAPI или других зависимостей.
- Ограниченная кастомизация: Гибкость зачастую ограничивается рамками функциональности, предусмотренной библиотекой.
- Избыточность: Такие решения иногда включают больше возможностей, чем требуется для конкретного проекта, что может утяжелить код.
Поэтому в рамках этой серии статей мы разработаем свою собственную систему управления пользователями. Это позволит не только настроить её под нужды проекта, но и детально разобраться в процессах, связанных с регистрацией, авторизацией и работой с профилем.
В следующих статьях мы шаг за шагом рассмотрим:
- создание модели пользователя,
- реализацию регистрации и авторизации,
- управление сессиями,
- и другие важные аспекты, связанные с системой пользователей.
Присоединяйтесь — будет интересно!
Базовая модель
Начнём не с модели пользователя, а с создания базовой модели, от которой будут наследоваться все остальные. Это позволит инструменту миграций Alembic
корректно отслеживать изменения в структуре базы данных.
В пакете lkeep
создадим новый пакет database
, а в нём ещё один пакет models
. В этом пакете будут находиться все файлы с моделями базы данных.
Внутри пакета models
создадим файл base.py
, в котором опишем базовую модель.
Класс Base
Создадим класс Base
, который будет унаследован от DeclarativeBase
из SQLAlchemy. Этот класс предназначен для декларативного стиля работы с таблицами базы данных, где таблицы описываются в виде Python-классов.
Основная задача базовой модели — упростить создание других моделей, обеспечив автоматическое назначение названий таблиц. Для этого в классе Base
мы реализуем метод __tablename__
.
Метод __tablename__
будет декорирован с помощью @declared_attr
, который используется для атрибутов, значения которых нужно вычислить на уровне класса. Метод принимает аргумент cls
(ссылку на класс) и возвращает строку — имя класса, преобразованное в нижний регистр.
Благодаря этому подходу, в дочерних классах не нужно вручную задавать атрибут __tablename__
: он будет автоматически установлен на основе имени класса. Например, для класса User
таблица в базе данных будет названа user
. Если же в дочернем классе явно указать __tablename__
, это значение переопределит метод из базовой модели.
Код класса
from sqlalchemy.orm import DeclarativeBase, declared_attr
class Base(DeclarativeBase):
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()
Преимущества такого подхода:
- Упрощение кода: Устраняется необходимость вручную задавать имя таблицы в каждой модели.
- Снижение ошибок: Автоматическое назначение названий уменьшает вероятность опечаток.
- Гибкость: Если потребуется задать индивидуальное имя таблицы, это можно сделать, явно указав
__tablename__
в дочернем классе.
Теперь у нас есть базовая модель, которая станет основой для всех остальных таблиц в проекте. В следующих частях мы будем создавать модели, наследующие Base
.
Миксины
Одной из важных особенностей написания моделей в SQLAlchemy являются миксины (mixins).
Миксины — это классы, которые предоставляют дополнительные поля, методы или изменяют поведение моделей под определённые условия. Они позволяют повторно использовать общий код, следуя принципу DRY (Don't Repeat Yourself), что делает проект более удобным для сопровождения.
Зачем нужны миксины?
Миксины помогают избежать дублирования кода, когда однотипные поля или функциональность требуются в нескольких моделях. Вместо того чтобы повторять код, можно просто наследоваться от нужного миксина.
Например:
- Поле для уникального идентификатора (ID).
- Методы для работы с датами создания и обновления записей.
- Логика для мягкого удаления записей (soft delete).
Наши миксины
Мы создадим несколько миксинов для:
- Идентификатора записи: Добавляет UUID или целочисленный идентификатор в модели.
- Даты создания: Автоматически указывает дату и время, когда запись была создана.
- Даты обновления: Автоматически обновляет время, когда запись была изменена.
Структура
В пакете database
создадим новый пакет mixins
. В этом пакете будут находиться файлы с миксинами. Например, для миксина с датами можно создать файл timestamps_mixin.py
.
Этот подход поможет разделить код миксинов по функциональным категориям, сохранив читаемость и удобство навигации.
В следующем разделе мы разберём создание одного из таких миксинов — миксина для идентификатора записи.
Миксин первичного ключа (Primary key)
Если вы работали с моделями в Django, то знаете, что при создании модели первичный ключ автоматически добавляется базовой моделью. Это упрощает разработку, особенно если не нужно задавать особую логику для ключа. Мы реализуем аналогичный подход, но с использованием миксина.
В пакете mixins
создадим файл id_mixins.py
.
В этом файле объявим класс IDMixin
, который будет предоставлять поле id
для всех моделей, использующих его.
Поле id
Поле id
будет служить уникальным идентификатором для каждой записи в таблице. Вот его основные особенности:
- Тип данных: Используется
Mapped[uuid.UUID]
, что позволяет SQLAlchemy сопоставить данные с Python-типомuuid.UUID
. - Уникальность: Поле будет помечено как первичный ключ (
primary_key=True
). - Генерация значений: Значение по умолчанию будет автоматически генерироваться с использованием функции
uuid.uuid4()
.
Почему UUID
?
UUID (Universally Unique Identifier) — это стандартный формат для уникальных идентификаторов. Среди разных версий наиболее популярна версия 4 (UUIDv4), которая генерирует идентификаторы случайным образом, используя криптографически безопасные методы.
Преимущества использования UUID:
- Глобальная уникальность: Исключается вероятность конфликта даже между записями, созданными в разных базах данных.
- Необходимость в уникальности на уровне распределённых систем: Например, если ваша система должна работать с несколькими серверами или микросервисами, UUID становится отличным выбором.
- Простота: UUID избавляет от необходимости управлять автоинкрементируемыми числами, которые могут потребовать сложной настройки в распределённых базах.
Использование mapped_column
Поле id
будет создано с помощью функции mapped_column
, которая позволяет описать связь атрибута класса с конкретным столбцом базы данных.
Мы зададим следующие параметры:
- Тип данных:
UUID(as_uuid=True)
— хранит идентификатор в базе данных в виде строки, но возвращает его в виде Python-объектаuuid.UUID
. - primary_key=True: Указывает, что это поле будет основным ключом таблицы.
- default=uuid.uuid4: Значение по умолчанию, которое автоматически генерирует уникальный UUID для новых записей.
Пример кода миксина
import uuid
from sqlalchemy import UUID
from sqlalchemy.orm import Mapped, mapped_column
class IDMixin:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
Преимущества подхода
- Повторное использование: Миксин можно подключить к любой модели, требующей уникального идентификатора.
- Чистота кода: Нет необходимости дублировать поле
id
в каждой модели. - Совместимость: Использование UUID делает идентификаторы гибкими и подходящими для распределённых систем.
Теперь каждая модель, наследующая IDMixin
, автоматически получит уникальный идентификатор, избавляя от необходимости вручную добавлять это поле.
Миксины временных меток (timestamps)
Поля, такие как created_at
или updated_at
, встречаются во многих моделях. Чтобы не дублировать их в каждой модели, создадим миксины, которые будут отвечать за эти поля, и будем использовать их в нужных моделях через наследование.
В пакете mixins
создадим новый файл timestamp_mixins.py
.
В этом файле мы определим три класса:
CreatedAtMixin
— для поляcreated_at
.UpdatedAtMixin
— для поляupdated_at
.TimestampsMixin
— комбинированный миксин, объединяющий оба поля.
Класс CreatedAtMixin
Создадим класс CreatedAtMixin
, который будет добавлять в модель поле created_at
. Это поле будет содержать дату и время создания записи.
Тип данных для поля created_at
зададим как Mapped[datetime.datetime]
, чтобы данные, полученные из базы данных, автоматически преобразовывались в объекты типа datetime
.
Для поля укажем следующие параметры:
DateTime(timezone=True)
: Это тип данных SQLAlchemy, который хранит дату и время с учётом временной зоны.server_default=func.now()
: Значение по умолчанию для базы данных, которое будет установлено сервером при создании записи.default=datetime.datetime.now
: Значение по умолчанию для Python, если в базе данных не указано иное.
Этот миксин будет автоматически добавлять поле created_at
, которое будет заполнено временем создания записи.
Код класса:
import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
class CreatedAtMixin:
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), default=datetime.datetime.now
)
Класс UpdatedAtMixin
Класс UpdatedAtMixin
почти идентичен предыдущему, за исключением одного важного момента:
- Вместо аргумента
default
, используется параметрonupdate
, который указывает SQLAlchemy обновить это поле при изменении записи.
Поле updated_at
будет автоматически обновляться при изменении записи, что полезно для отслеживания времени последнего обновления.
Код класса:
class UpdatedAtMixin:
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
Класс TimestampsMixin
Если нам нужно использовать оба поля — created_at
и updated_at
— можно создать комбинированный миксин TimestampsMixin
. Этот класс будет просто наследоваться от двух предыдущих и добавлять оба поля в модель, не дублируя их код.
В теле класса не нужно прописывать дополнительные атрибуты или методы, так как поля и логика уже прописаны в наследуемых классах.
Код класса:
class TimestampsMixin(CreatedAtMixin, UpdatedAtMixin):
pass
Как это использовать?
Теперь, чтобы добавить временные метки в модель, достаточно просто наследовать её от одного из миксинов:
- Если нужна только дата создания, наследуем от
CreatedAtMixin
. - Если нужно отслеживание времени последнего обновления, наследуем от
UpdatedAtMixin
. - Если нужны оба поля, используем
TimestampsMixin
.
Теперь все модели, использующие эти миксины, будут автоматически включать поля created_at
и updated_at
, с правильной логикой их заполнения и обновления.
Модель пользователя
Теперь создадим модель пользователя, которая будет включать все необходимые поля для работы с пользователями в нашем приложении. В пакете models
, где мы уже разместили базовую модель, создадим новый файл user.py
. В этом файле опишем класс User
, который будет унаследован от следующих компонентов:
- Миксин идентификатора
IDMixin
(для уникального идентификатора пользователя), - Миксин временных меток
TimestampsMixin
(для автоматического добавления временных меток), - Базовой модели
Base
(для обеспечения совместимости с миграциями).
Поля модели
Модель пользователя будет включать пять ключевых полей, каждое из которых выполняет важную роль:
email
:- Поле для хранения электронной почты пользователя. Это строковое поле с максимальной длиной 100 символов.
- Поле должно быть уникальным (
unique=True
), так как каждому пользователю назначается уникальный email, и обязательным (nullable=False
), чтобы предотвратить создание записи без указания email.
hashed_password
:- Поле для хранения пароля пользователя в хешированном виде. Используется тип
Text
, так как хеш пароля может быть достаточно длинным. - Это поле не уникально (
unique=False
), но обязательно для заполнения (nullable=False
).
- Поле для хранения пароля пользователя в хешированном виде. Используется тип
is_active
:- Булево поле, которое указывает, активен ли пользователь или его аккаунт заблокирован. Это полезно для управления доступом, например, если аккаунт пользователя был деактивирован.
- По умолчанию значение —
False
, что означает, что новый пользователь считается неактивным, пока не подтвердит свою регистрацию.
is_superuser
:- Булево поле, которое указывает, является ли пользователь администратором или обладает ли он расширенными правами доступа.
- По умолчанию значение —
False
, то есть, по умолчанию пользователи не имеют административных прав.
is_verified
:- Булево поле, которое определяет, подтвердил ли пользователь свою электронную почту после регистрации. Это важно для предотвращения фейковых регистраций и спама.
- По умолчанию значение —
False
, что означает, что пользователь считается неподтверждённым до тех пор, пока не выполнит верификацию своей почты.
Код модели:
from sqlalchemy import Boolean, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lkeep.database.mixins.id_mixins import IDMixin
from lkeep.database.mixins.timestamp_mixins import TimestampsMixin
from lkeep.database.models.base import Base
class User(IDMixin, TimestampsMixin, Base):
email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(Text, unique=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
Видимость моделей для Alembic
Чтобы система миграций Alembic могла "увидеть" наши модели и корректно работать с ними при создании миграций, необходимо выполнить несколько шагов. Этот процесс является ключевым, так как без правильной настройки Alembic не сможет отслеживать изменения в моделях базы данных и создавать соответствующие миграции.
Добавление __all__
и импортов
- Открываем
__init__.py
в пакетеmodels
:- Файл
__init__.py
используется для того, чтобы Python воспринимал директорию как пакет. В нашем случае этот файл будет служить для экспорта моделей, которые мы хотим сделать доступными для Alembic. Это важно, потому что Alembic использует эти импорты для отслеживания изменений в структуре базы данных.
- Файл
- Добавляем
dunder-атрибут
__all__
:- Атрибут
__all__
— это специальная конструкция в Python, которая указывает, какие имена (модели) должны экспортироваться, когда кто-то выполняет импорт всех объектов из пакета с помощью конструкцииfrom models import *
. - Мы перечисляем все модели, которые должны быть доступны для Alembic, чтобы он мог корректно отслеживать их изменения.
- Атрибут
- Импорты моделей:
- После добавления атрибута
__all__
необходимо явно импортировать все модели, которые мы перечислили, чтобы Alembic мог работать с ними при генерации миграций. Это гарантирует, что Alembic будет "знать" о всех моделях и их структуре.
- После добавления атрибута
Пример кода:
В файле __init__.py
в пакете models
добавим следующее:
from lkeep.database.models.base import Base
from lkeep.database.models.user import User
__all__ = ("Base", "User")
Как это работает?
- Когда Alembic будет создавать миграции, он будет искать все модели, упомянутые в атрибуте
__all__
. Для этого ему нужно знать, где они находятся, и какие классы доступны для работы. - В данном примере мы импортируем класс
Base
(основной базовый класс для всех моделей) и классUser
(модель пользователя). - Благодаря экспорту через
__all__
, эти модели будут автоматически видны для Alembic, и миграции будут корректно генерироваться на основе изменений в этих моделях.
Теперь, при создании миграций, Alembic сможет видеть наши модели и корректно отслеживать изменения в базе данных.
Установка и инициализация Alembic
Для работы с базой данных и управления её миграциями в нашем проекте мы будем использовать Alembic — инструмент для создания и применения изменений в структуре базы данных. Alembic позволяет автоматически генерировать SQL-скрипты для миграций, а также поддерживает их применение и откат. Этот процесс схож с миграциями в Django, что делает его понятным для тех, кто уже знаком с этим фреймворком.
Установка Alembic
Чтобы установить Alembic в проект, выполните следующую команду с использованием Poetry для управления зависимостями:
poetry add alembic ruff
alembic
— это сам инструмент для создания и применения миграций в базе данных.ruff
— это дополнительный инструмент для автоматического форматирования кода, который помогает поддерживать стиль кода в проекте.
Инициализация Alembic
После установки Alembic, необходимо инициализировать его в проекте. Для этого выполните команду:
alembic init --template async lkeep/database/alembic
Здесь:
--template async
— указывает на использование асинхронного шаблона для работы с базой данных. Это важно для проектов, использующих FastAPI или другие асинхронные фреймворки, так как позволяет использовать асинхронные подключения к базе данных.lkeep/database/alembic
— это путь к директории, где будут храниться все файлы настроек и миграций для Alembic.
Важно: Принято размещать директорию с миграциями в пакете для работы с базой данных, чтобы структура проекта оставалась организованной и модульной.
Результат
После выполнения команды в вашем проекте будут созданы следующие файлы и директории:
alembic.ini
— основной конфигурационный файл для Alembic, в котором можно настроить параметры подключения к базе данных и другие настройки миграций.lkeep/database/alembic/
— директория, в которой будут храниться все миграции и файлы конфигурации Alembic. В ней появятся следующие файлы:env.py
— скрипт, который используется для подключения к базе данных и управления миграциями.versions/
— папка, где будут храниться версии миграций, которые генерируются при изменении моделей.
Настройка Alembic
После инициализации Alembic необходимо внести изменения в конфигурацию, чтобы корректно подключиться к базе данных и настроить форматирование миграций.
Шаг 1: Изменение alembic.ini
Откроем файл alembic.ini
, который был создан при инициализации Alembic. В этом файле потребуется раскомментировать и изменить несколько строк:
10-я строка — задаёт шаблон имени файлов для миграций:
Найдите и измените следующую строку:
# было
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# стало
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
Эта настройка определяет формат имени файлов миграций, позволяя создавать уникальные и легко читаемые имена для файлов миграций.
Строки 77-80 — настройки для инструмента форматирования кода ruff
:
Раскомментируйте и измените следующие строки, чтобы настроить автоматическое форматирование всех файлов миграций с помощью ruff:
# было
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# стало
hooks = ruff
ruff.type = exec
ruff.executable = poetry
ruff.options = run ruff format REVISION_SCRIPT_FILENAME
Это обеспечит автоматическое форматирование миграций при их создании с использованием ruff
. Убедитесь, что строки не содержат лишних пробелов в начале.
Закомментируйте строку, содержащую URL подключения к базе данных:
Найдите строку с параметром sqlalchemy.url
и закомментируйте её:
# было
sqlalchemy.url = driver://user:pass@localhost/dbname
# стало
# sqlalchemy.url = driver://user:pass@localhost/dbname
Шаг 2: Изменение env.py
Теперь откроем файл env.py
, который находится в директории alembic
. Это скрипт, который Alembic использует для настройки и применения миграций. Мы сделаем несколько важных изменений.
Найдите строку config = context.config
и добавьте после неё следующий код для настройки URL подключения к базе данных через конфигурацию:
from lkeep.core.settings import settings
config.set_main_option("sqlalchemy.url", settings.db_settings.db_url.unicode_string())
Этот код позволяет Alembic использовать строку подключения, которая будет извлечена из настроек проекта.
Далее найдите строку target_metadata = None
и измените её на:
from lkeep.database.models.base import Base
target_metadata = Base.metadata
Это нужно для того, чтобы Alembic знал, какие метаданные моделей нужно использовать при создании миграций. В этом случае мы указываем на базовый класс Base
, который используется для всех моделей.
Теперь Alembic будет правильно подключаться к базе данных и отслеживать изменения в моделях для генерации миграций.
Первые миграции
После настройки Alembic можно создать первые миграции, чтобы убедиться, что всё работает корректно и база данных обновляется согласно изменениям в моделях.
Шаг 1: Создание миграции
Откройте терминал и выполните команду для создания миграции:
alembic revision --autogenerate -m "Create User Table"
- Флаг
--autogenerate
указывает Alembic автоматически сгенерировать файл миграции, сравнив текущие модели с текущей схемой базы данных. - Флаг
-m
позволяет добавить описание миграции (аналогично коммитам в Git), например, "Create User Table".
После выполнения команды в директории versions
должна появиться новая миграция. Обычно Alembic создаёт файл с именем, включающим хеш и описание миграции. Например: 2025_01_10_1706-ccf7560dd457_create_user_table.py
.
Важный момент:
После генерации миграции обязательно откройте файл и внимательно его проверьте. Alembic может не всегда корректно обработать все изменения, особенно если используются нестандартные или сложные типы данных. Убедитесь, что все поля были правильно определены, а также что никаких лишних изменений не было внесено.
Пример содержимого файла миграции, который Alembic мог бы создать:
"""Create User Table
Revision ID: ccf7560dd457
Revises:
Create Date: 2025-01-10 17:06:05.585011
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "ccf7560dd457"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user",
sa.Column("email", sa.String(length=100), nullable=False),
sa.Column("hashed_password", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_superuser", sa.Boolean(), nullable=False),
sa.Column("is_verified", sa.Boolean(), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("user")
# ### end Alembic commands ###
Шаг 2: Применение миграции
После того как вы убедились, что файл миграции корректно сгенерирован, можно применить миграцию, чтобы обновить базу данных:
alembic upgrade head
Эта команда применяет все изменения, которые были описаны в миграциях, начиная с текущей и до самой последней. В нашем случае это создаст таблицу user
в базе данных.
Пример вывода, который вы должны увидеть в терминале:
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> ccf7560dd457, Create User Table
Если миграция была успешной, Alembic выведет информацию о применении миграции и об успешном обновлении базы данных.
Шаг 3: Проверка
Теперь можно подключиться к базе данных (например, с помощью pgAdmin, DBeaver или командной строки) и проверить, создались ли таблицы и поля.
В нашем случае, если миграция прошла успешно, в базе данных должна появиться таблица user
с полями email
, hashed_password
, is_active
, is_superuser
, is_verified
, created_at
, updated_at
, а также уникальным ограничением на email
и первичным ключом на id
.
Примечания:
Откат миграции: Если вы хотите откатить миграцию и вернуться к предыдущей версии базы данных, используйте команду:
alembic downgrade -1
Эта команда откатит последнюю миграцию. Если нужно откатить несколько миграций, можно использовать команду с указанием количества шагов, например:
alembic downgrade -2
Проверка текущего состояния миграций: Чтобы увидеть, какие миграции уже применены, можно использовать команду:
alembic history --verbose
Автоматическое тестирование: Всегда полезно после применения миграций запустить тесты, если они есть, чтобы проверить, что миграции не вызвали неожиданных ошибок.
Заключение
В этом посте мы создали базовую модель данных, миксины с дополнительными полями, модель пользователя, настроили и применили миграции с помощью Alembic. Мы также установили библиотеку ruff
для автоматического форматирования файлов миграций и обеспечили, чтобы код соответствовал стандартам PEP-8. Это лишь первый шаг на пути к созданию системы пользователей и аутентификации, и впереди нас ждёт ещё много интересных задач.
P.S. Для поддержания порядка в коде можно использовать библиотеку ruff
для форматирования миграций и других файлов. Чтобы отформатировать файлы вручную, выполните команду:
ruff format .
Эта команда проверит все .py
файлы в текущей директории на соответствие стандартам PEP-8 и автоматически отформатирует их, если это необходимо. Вы также можете указать конкретную директорию или файл вместо точки, чтобы сузить область проверки.
Репозиторий проекта в GitHub: https://github.com/proDreams/lkeep
Репозиторий проекта в "GIT на салфетке": https://git.pressanybutton.ru/proDream/lkeep
Все статьи