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). Миграции, кстати, асинхронность не используют, они могут быть исключительно блокирующими.

Извините, что получилось немного скомканно про асинхронность - но я, действительно, немного тороплюсь. И документацию я тоже не успел обновить - здесь лежит её сырая версия.