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

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

Комментарии (0)