Давно преследует одна и та же боль в любом растущем API-проекте на Python: таблица маршрутов начинает жить своей жизнью. @app.post("/v2/user/role") лежит в файле, который импортируется через два уровня include_router, реальная бизнес-логика — в третьем файле, а понять «какой версии принадлежит этот эндпоинт» превращается в археологию по grep’у.
Я решил проверить гипотезу: а что если убрать таблицу маршрутов вообще? Не «сделать её удобнее» — а убрать как отдельную сущность. Пусть структура каталогов и будет таблицей маршрутов.
Так получился EndoCore — ASGI-фреймворк, который я пишу как pet-проект последние несколько месяцев. Дошёл до состояния «можно показывать людям»: 1679 тестов, PyPI, MkDocs-сайт на двух языках. Делюсь — что получилось, что скучно, и где я сам не уверен, что это хорошая идея.
Идея
Api/v1/User/[id]/Get.py -> GET /v1/user/42 (id="42") Api/v1/User/Role/Post.py -> POST /v1/user/role Api/v2/User/[id]/Get.py -> GET /v2/user/42 (v1 продолжает работать)
Кладёшь файл в нужную папку — эндпоинт существует. Роутинг, версионирование, end routes для интроспекции — всё это операции над одним и тем же деревом каталогов, а не три разные подсистемы, которые могут разъехаться.
Сам файл — просто функция:
# Api/v1/User/Role/Post.py -> POST /v1/user/role from endocore import Request, Response async def handler(request: Request) -> Response: data = await request.json() return Response.json({"created": data["name"]}, status=201)
Никакого app = FastAPI(), никакого декоратора, никакой регистрации руками. Удалил файл — эндпоинта нет. end routes не парсит декораторы в поисках роутов — он просто печатает дерево, потому что дерево уже и есть ответ.
Версионирование — это copytree, а не конфиг
end version create 2 # Api/v1 -> Api/v2, endpoints + локальные сервисы
v1 физически не может измениться, когда меняется v2 — это не два роутера, разделяющих состояние, а две независимые копии поддерева. Никаких if version == 2 веток, гниющих внутри одного хендлера годами.
ORM: безопасность — не опция, а единственный путь
Я довольно много времени убил именно на ORM, потому что для меня “фреймворк ради фреймворка” неинтересен — интересно, когда каждый примитив спроектирован так, что нельзя выстрелить себе в ногу случайно.
from endocore.orm import Model, fields, configure, create_all, Q, F class User(Model): name = fields.CharField(max_length=100) age = fields.IntegerField(default=0) active = fields.BooleanField(default=True) configure(backend="sqlite", database="app.db") # или postgres, pool_size=10, ... create_all(User) User.objects.filter(age__gte=18).order_by("-age") User.objects.filter(Q(age__lt=18) | Q(name__icontains="a")) User.objects.filter(pk=1).update(age=F("age") + 1) # атомарный F() # неблокирующе для ASGI: async with endocore.orm.aatomic(): user = await User.objects.aget(pk=1) await user.asave()
Значения всегда идут через биндинг драйвера — никогда не через f-строку. Идентификаторы (таблицы/колонки) валидируются регэкспом и квотятся. Лукапы — строгий whitelist, неизвестный лукап падает с ошибкой, а не тихо проваливается в сырой SQL. Это не «слой безопасности, который можно выключить» — это единственный способ, которым ORM вообще умеет строить запрос.
Есть connection pooling (для PostgreSQL — реальный параллелизм транзакций, не один лок на всех), миграции с откатом, шифрование файлов (AES-256-GCM, если утечёт диск — без ключа файлы бесполезны), встроенные сессии на HMAC-подписанных куках и scrypt для паролей — без сторонних auth-библиотек.
Проверка боем: не просто демки, а гоночные тесты
Написать «у меня асинхронно и с транзакциями» — легко. Доказать, что это реально работает под конкурентностью — совсем другое дело. Поэтому вместо одного тривиального демо-приложения я сделал три, и специально проверил их race conditions:
Kanban-борда с живыми обновлениями по WebSocket (pub/sub комнаты).
Бронирование слотов — тест на 8 одновременных запросов на один и тот же слот: ровно один получает
201, остальные409, в базе ровно одна запись.Магазин с идемпотентными покупками и вебхуком платёжного шлюза — 6 одновременных дублирующихся запросов не списывают деньги дважды, retry вебхука после сбоя — тоже.
Отдельно есть тестовый сьют, который гоняет пул соединений против реального PostgreSQL (tests/orm/test_postgres_pool.py), а не только против SQLite в памяти — потому что конкурентность на SQLite и на настоящей БД с несколькими воркерами это буквально разные миры, и я не хотел врать себе, что «раз тесты зелёные — значит работает везде».
Цифры (с честными оговорками)
Python 3.14, static GET: EndoCore 23 800 req/s vs FastAPI 11 000 req/s (2.2×) Python 3.14, dynamic GET: EndoCore 30 800 req/s vs FastAPI 8 600 req/s (3.6×)
Но! Это чистый оверхед диспетчеризации — вызов ASGI-callable напрямую, без сети, без базы. FastAPI платит эту цену за pydantic-валидацию и сериализацию, которые включены всегда, даже если конкретному эндпоинту они не нужны. В реальном приложении с базой данных эта разница тонет в latency одного SQL-запроса. Я не хочу продавать бенчмарк как причину выбрать фреймворк — это просто честные цифры, полная методология в доках.
Чего в EndoCore нет и, скорее всего, не будет
Нет своей экосистемы плагинов размером с FastAPI/Django — это моложе на порядки.
Нет ORM-миграций для сложных data-трансформаций — только структура таблиц, для сложного переноса данных пишешь скрипт руками.
WebSocket pub/sub — однопроцессный; для fan-out между воркерами нужен Redis поверх (описано в доках, но это ваша забота, не встроенное).
Это по-прежнему бета (
0.7.0b2), не 1.0. Я использую его сам, но не претендую, что это готово для чужого продакшена без вдумчивого чтения кода — благо, кода мало и он читается за вечер.
Как попробовать
pip install "endocore[watch]" end new blog && cd blog end dev # http://127.0.0.1:8000/docs
end — зарезервированное слово в PowerShell, там алиас endo.
Ссылки
Документация (EN/RU): https://endocore.readthedocs.io
Демки с race-тестами:
demos/в репозитории (kanban, booking, shop)Discord: https://discord.gg/jwvGj2M9EX
Буду рад любой обратной связи — особенно если найдёте, где я наврал себе про безопасность или производительность. Личный проект, но отношусь к нему серьёзно.
