DISCLAIMER: Это неофициальный форк. Но, по моим сведениям, и в официальный pony эти фичи скоро подъедут в каком-то виде.
Некоторое время назад, я решил испробовать программирование с помощью ИИ-агентов и решил выбрать pony в качестве пет-проекта. О pony я уже писал в предыдущей статье. Теперь хочу поделиться результатами своего недельного спринта.
В pony добавилось:
- поддержка асинхронности
- поддержка миграций
- разные улучшения из серии quality-of-life
Так что, фреймворк pony теперь чрезвычайно важен и сердит - что можно понять по этой картинке. Всё - благодаря DeepSeek 4.1 Flash и Kimi K3 (последняя - умная, но дорогая - она делала ревью).

Я думаю, проще всего проиллюстрировать новые фичи на каком-нибудь примере, который чуть сложнее, чем hello world.
Сначала нужно установить pony. Я сделал свой собственный форк poney - чтобы его установить, нужно выполнить
uv add poney
Кстати, приятный побочный эффект моей деятельности - в том, что авторы pony, наконец, активизировали свои усилия по реализации давно назревших фич и планируют их зарелизить уже в скором будущем. Реализация, думаю, будет отличаться - потому что мы работали независимо. Тем не менее, очень вероятно, что скоро можно будет использовать pony из апстрима с примерно тем же результатом.
Итак, мы установили пони. Дальше, нужно создать Database:
from pony.orm import Database db = Database(provider=‘postgres’, dsn=“dbname=poney user=postgres”)
Кстати, опции dsn раньше не было - мне её предложил добавить DeepSeek, когда я его попросил расширить возможности декларации сущностей (таблиц), сделав его по возможности поближе к чистому SQL (точнее, DDL). В числе прочего, он добавил и CHECK constraints:
from pony.orm import constraint class Person(db.Entity): name = Required(str) age = Required(int) cars = Set('Car') @constraint.check def adults_only(self): return self.age >= 18 class Car(db.Entity): make = Required(str) model = Required(str) owner = Required(Person)
В апстримном pony, чтобы пользоваться сущностями, нужно выполнить
db.generate_mapping(create_tables=True)
create_tables можно не передавать, если таблицы в базе уже созданы. Кстати, теперь у нас есть для этого миграции. Попробуем воспользоваться ими:
db.migrations.make()
То же самое можно сделать и из командной строки (и это, на самом деле, и есть основной сценарий). Но я специально оставил дублирующий его питоновский API, чтобы, например, миграциями было удобно пользоваться в ноутбуках Jupyter. Из командной строки это делается так:
pony migrations make --db models:db
Опцию --db нужно указать всего один раз: она запишется в файл config.ini в папке migrations. Итак, запускаем:
MigrationError: No applications are registered: migrations are per-application (db.application(...), see pony-apps); entities without an application are not covered by migrations
Ошибка: нам говорят, что миграции поддерживаются только для сущностей, которые принадлежат какому-то "приложению".
Идея database applications взята из django. Только я пошёл ещё дальше и сделал однозначное соответствие схема <-> приложение. Правда, не все базы данных поддерживают разные схемы внутри одной базы. Для тех баз, которые не поддерживают схемы, приложения - это просто логическая сущность, позволяющая группировать таблицы. Никакого префикса в названия таблиц, в этом случае, не добавляется - в отличие от django.
Но, если вы используете postgres, то ваши таблицы уже принадлежат каким-то схемам (по дефолту - public). Остаётся только указать этот факт явным образом:
database = Database(provider=‘postgres’, dsn=“dbname=poney user=postgres”) db = database.application('public')
Сгенерируем миграции:

Сгенерировался 0001_initial.sql. Выглядит он так:
CREATE TABLE "public"."person" ( "id" SERIAL PRIMARY KEY, "name" TEXT NOT NULL, "age" INTEGER NOT NULL, CONSTRAINT "chk_person__adults_only" CHECK ("age" > 18) ); CREATE TABLE "public"."car" ( "id" SERIAL PRIMARY KEY, "make" TEXT NOT NULL, "model" TEXT NOT NULL, "owner" INTEGER NOT NULL ); CREATE INDEX "idx_car__owner" ON "public"."car" ("owner"); ALTER TABLE "public"."car" ADD CONSTRAINT "fk_car__owner" FOREIGN KEY ("owner") REFERENCES "public"."person" ("id") ON DELETE CASCADE
Его можно применить командой apply:
pony migrations apply
Если что, миграции схемы выполняются внутри DDL-транзакции. Все миграции схемы - это обычный SQL. В целом, идея в том, чтобы было удобно использовать pony c уже имеющейся базой данных.
Кроме миграций схемы, ещё поддерживаются так называемые миграции данных. Это - питоновские скрипты. Миграцию данных можно создать так:

Вот что создалось:
# depends: 0001_initial.sql from pony.orm import Database db = Database.instance().new() db.introspect() if __name__ == '__main__': pass
Это обычный питоновский скрипт почти без какой-либо магии (вся магия - в том, что он выполняется внутри db_session). Разберём, что означает каждая строчка.
Первая строчка с комментарием определяет зависимости миграции. Зависимостей у миграции может быть сколько угодно. Воркфлоу с миграциями будет хорошо знаком тем, кто работал с django. Например, при наличии "нескольких голов" вас попросят сделать мердж-миграцию:

Мердж-миграция - это файл с расширением txt. Выглядит она примерно так:
-- depends: 0002_add_data.py, 0002_other_migration.py 0003_merge.txt │ ┌────────────┴─────────────┐ │ │ 0002_add_data.py 0002_other_migration.py │ │ └────────────┬─────────────┘ │ 0001_initial.sql
Но вернёмся к миграции 0002_add_data.py. Следующей строчкой идёт
db = Database.instance().new()
Ну, почему там Database.instance() - ещё можно понять: миграции знают, где взять database. Но почему там .new(), и потом .introspect()?
Дело в том, что тот объект Database, который мы импортировали из нашего проекта, нам не подходит. Он слишком свежий, и миграции нужен не он, а его "снимок" из прошлого. В django, например, написано много кода для того, чтобы уметь восстанавливать этот "снимок" по файлам миграций. Я выбрал более простой путь - режим интроспекции.
Мы пытаемся восстановить наши модели-сущности по состоянию базы данных. При этом, то, что восстановить не удается, мы руками дописываем в файл миграции:
# depends: 0001_initial.sql from pony.orm import * db = Database.instance().new() public = db.application('public') class Person(public.Entity): cars = Set('Car') db.introspect() me = public.Person(name='Vitalik', age=37) car = public.Car(make='Toyota', model='Camry', owner=me)
Сессию в миграции создавать не нужно: она создаётся автоматически. Кстати, в рамках этой же сессии, в таблицу pony_migrations добавится строчка о том, что миграция была успешно выполнена.
Миграции данных удобно писать и отлаживать, например, в Jupyter ноутбуках:

Для того, чтобы увидеть, что мы получаем из базы интроспекцией, есть опция dump :
db = database.new() db.introspect('public', dump='introspected.py')
В нашем случае, файл introspected.py выглядит так:
from pony.orm import * class Car(db.Entity): id = PrimaryKey(int, auto=True, name='car_pkey') make = Required(str) model = Required(str) owner = Required('Person', index='idx_car__owner', reverse='car_set') class Person(db.Entity): id = PrimaryKey(int, auto=True, name='person_pkey') name = Required(str) age = Required(int) car_set = Set('Car', reverse='owner')
car_set - это дефолтный атрибут для коллекции. В нашей миграции мы его переопределили на cars.
Это всё, что я хотел сказать о миграциях - и так получилось длинно. Теперь скажу пару слов об асинхронности. Поддерживаются postgres и mariadb/mysql. Оба драйвера позволяют работать с пулом соединений, чем мы и пользуемся. После использования для какой-либо сессии, соединение возвращается в пул.
Принцип очень простой: мы пишем async with db_session, и после этого обычные методы становятся асинхронными:
async with db_session: me = await Person[1]
То же самое касается методов get и select, а также .load() - подгрузки частично загруженного объекта:
await obj.load()
Методы flush и commit/rollback тоже становятся асинхронными. Операции записи остаются синхронными, потому что мы всё равно записываем в базу отложенным образом - в конце сессии.
Такой подход стал возможен, потому что API pony построен вокруг работы с сессиями. Поэтому, мы точно можем сказать, находимся мы внутри блокирующей или асинхронной сессии. В django так бы сделать не получилось.
Важный момент: асинхронность в пони самая что ни на есть нативная - безо всяких гринлетов (как это сделано, например, в sqlalchemy). Миграции, кстати, асинхронность не используют, они могут быть исключительно блокирующими.
Извините, что получилось немного скомканно про асинхронность - но я, действительно, немного тороплюсь. И документацию я тоже не успел обновить - здесь лежит её сырая версия.

