xtr-orm 1.5.0__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/PKG-INFO +22 -13
  2. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/README.md +16 -7
  3. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/pyproject.toml +14 -11
  4. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/pyproject.toml.orig +15 -11
  5. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/SKILL.md +282 -0
  6. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/commands.md +67 -0
  7. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/connections.md +97 -0
  8. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/messenger.md +95 -0
  9. xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/migrations.md +71 -0
  10. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/connection_config.py +3 -1
  11. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/orm_bundle.py +101 -64
  12. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/connection_command.py +18 -3
  13. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/database_create_command.py +1 -1
  14. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/database_drop_command.py +1 -1
  15. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_current_command.py +1 -1
  16. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_diff_command.py +3 -3
  17. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_dump_schema_command.py +2 -2
  18. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_execute_command.py +3 -3
  19. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_generate_command.py +2 -2
  20. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_latest_command.py +1 -1
  21. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_list_command.py +1 -1
  22. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_migrate_command.py +6 -8
  23. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_rollup_command.py +1 -1
  24. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_status_command.py +1 -1
  25. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_up_to_date_command.py +3 -8
  26. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_version_command.py +4 -3
  27. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/run_sql_command.py +1 -1
  28. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/connection_registry.py +18 -0
  29. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/close_connection_middleware.py +3 -4
  30. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/open_transaction_logger_middleware.py +20 -12
  31. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_environment.py +2 -1
  32. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/executed_migration.py +3 -1
  33. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migrations_config.py +3 -1
  34. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migrator.py +19 -6
  35. xtr_orm-3.0.0/src/xtr_orm/recipe/__init__.py +15 -0
  36. xtr_orm-3.0.0/src/xtr_orm/recipe/files/config/orm.py.tmpl +24 -0
  37. xtr_orm-3.0.0/src/xtr_orm/recipe/manifest.toml +32 -0
  38. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/LICENSE +0 -0
  39. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/__init__.py +0 -0
  40. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/__init__.py +0 -0
  41. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/orm_config.py +0 -0
  42. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/__init__.py +0 -0
  43. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/command/connections.py +0 -0
  44. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/database/__init__.py +0 -0
  45. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/database/database_manager.py +0 -0
  46. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/__init__.py +0 -0
  47. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/invalid_argument_error.py +0 -0
  48. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/migration_error.py +0 -0
  49. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/orm_error.py +0 -0
  50. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/session_unavailable_error.py +0 -0
  51. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/unknown_connection_error.py +0 -0
  52. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/unsupported_database_error.py +0 -0
  53. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/__init__.py +0 -0
  54. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/transaction_middleware.py +0 -0
  55. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/__init__.py +0 -0
  56. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_history.py +0 -0
  57. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_revisions.py +0 -0
  58. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_schema.py +0 -0
  59. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/available_migration.py +0 -0
  60. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/direction.py +0 -0
  61. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/execution_result.py +0 -0
  62. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migration_plan.py +0 -0
  63. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migration_status.py +0 -0
  64. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/template/script.py.mako +0 -0
  65. {xtr_orm-1.5.0 → xtr_orm-3.0.0}/src/xtr_orm/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-orm
3
- Version: 1.5.0
3
+ Version: 3.0.0
4
4
  Summary: Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection.
5
5
  Keywords: orm,database,migrations,sqlalchemy,alembic,asyncio
6
6
  Author: Xterr
@@ -20,11 +20,11 @@ Requires-Dist: advanced-alchemy>=1.11
20
20
  Requires-Dist: alembic>=1.20
21
21
  Requires-Dist: sqlalchemy[asyncio]>=2.0.41
22
22
  Requires-Dist: typing-extensions>=4.4
23
- Requires-Dist: xtr-logging-contracts>=1.0,<2
24
- Requires-Dist: xtr-console>=1.0,<2 ; extra == 'console'
25
- Requires-Dist: xtr-dependency-injection>=1.0,<2 ; extra == 'di'
26
- Requires-Dist: xtr-service-contracts>=1.0,<2 ; extra == 'di'
27
- Requires-Dist: xtr-messenger>=1.0,<2 ; extra == 'messenger'
23
+ Requires-Dist: xtr-logging-contracts>=3.0,<4
24
+ Requires-Dist: xtr-console>=3.0,<4 ; extra == 'console'
25
+ Requires-Dist: xtr-dependency-injection>=3.0,<4 ; extra == 'di'
26
+ Requires-Dist: xtr-service-contracts>=3.0,<4 ; extra == 'di'
27
+ Requires-Dist: xtr-messenger>=3.0,<4 ; extra == 'messenger'
28
28
  Requires-Dist: asyncmy>=0.2.9 ; extra == 'mysql'
29
29
  Requires-Dist: asyncpg>=0.29 ; extra == 'postgres'
30
30
  Requires-Dist: aiosqlite>=0.20 ; extra == 'sqlite'
@@ -152,7 +152,7 @@ the files, not its schema against your models; `diff` compares those.
152
152
 
153
153
  | Field | Default | Meaning |
154
154
  |---|---|---|
155
- | `directory` | `"migrations"` | Where revision files are read and written |
155
+ | `directory` | `"migrations"` | Where revision files are read and written; a relative path is relative to the working directory — under a kernel, start it with `%kernel.project_dir%` |
156
156
  | `version_table` / `history_table` | `"alembic_version"` / `"alembic_version_history"` | The two tables |
157
157
  | `version_table_schema` | `None` | Their schema; the connection's default when `None` |
158
158
  | `file_template` | `"{year}{month}{day}{hour}{minute}{second}_{rev}_{slug}"` | How a file is named; also `{epoch}` |
@@ -202,15 +202,19 @@ removing it undoes.
202
202
  - **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
203
203
  `postgres` for those databases; drop `console` to go without the commands; add `messenger`
204
204
  for the [message bus middleware](#message-bus-middleware).
205
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
206
+ *Environment* steps below: it lists `OrmBundle`, writes a starting `<app>/config/orm.py` with the
207
+ `default` connection, and `DATABASE_URL` in `.env`. It prints the middleware step, which a recipe
208
+ cannot make for you.
205
209
  - **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
206
210
  `xtr_orm.bundle`.
207
211
  - **Brings along** — the logging and console bundles, when those packages are installed.
208
212
  With the messenger bundle active too, the [middleware](#message-bus-middleware) are
209
213
  registered for a bus to list.
210
- - **Configure** — optional: with no configuration there is one connection, `default`, reading
211
- `DATABASE_URL`, with its revisions in `migrations` in the project directory and diffs
212
- against the models advanced-alchemy registers. Connections, replicas and table definitions go
213
- in `<app>/config/orm.py`, a `@configure` function returning `OrmConfig` — see
214
+ - **Configure** — the recipe writes a starting `<app>/config/orm.py` with the `default`
215
+ connection reading `DATABASE_URL`, its revisions in `migrations` in the project directory and
216
+ diffs against the models advanced-alchemy registers. Add connections, replicas and table
217
+ definitions to that `@configure` function returning `OrmConfig` — see
214
218
  [Kernel / bundle](#kernel--bundle).
215
219
  - **Environment** — `DATABASE_URL`, read when a connection is first used — engine, session,
216
220
  migrator or command — not when the application boots. Its query may carry options; see
@@ -406,8 +410,8 @@ MessageBusConfig(
406
410
  | Name | Class | Does | Arguments |
407
411
  |---|---|---|---|
408
412
  | `orm_transaction` | `TransactionMiddleware` | Runs every handler of a message in one transaction, committed once they all succeeded and rolled back otherwise; a failure drops the other handlers' handled stamps, so a retry runs them all again | `connection_name`, the default connection when left out |
409
- | `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection when left out |
410
- | `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error — to the application's logger when the logging bundle is active — when a handler began a transaction — `begin()`, `begin_nested()` — and left it open | `connection_names`, every connection when left out |
413
+ | `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection in use — its engine built — when left out |
414
+ | `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error — to the application's logger when the logging bundle is active — when a handler began a transaction — `begin()`, `begin_nested()` — and left it open, in a message that was handled | `connection_names`, every connection in use — its engine built — when left out |
411
415
 
412
416
  - **One session per message.** The messenger bundle makes every message a unit of work, and a
413
417
  scoped service — the session, a repository built on it — is the unit's: every handler of
@@ -463,6 +467,11 @@ Every error derives from `OrmError`.
463
467
 
464
468
  ## Known limitations
465
469
 
470
+ - **Migrations take no lock.** Two processes migrating one database at once — two deploys
471
+ starting together — both run the same revisions; run `orm:migrations:migrate` from one
472
+ place, before the application starts.
473
+ - **Applied times follow the database.** `executed_at` has a time zone on PostgreSQL and is
474
+ naive elsewhere: UTC on SQLite, the session's time zone on MySQL.
466
475
  - **advanced-alchemy's model registry is process-wide.** Its `metadata_registry` maps a
467
476
  `bind_key` to table definitions for the whole process, so two kernels in one process share
468
477
  it. Give a connection its `metadata` explicitly to keep a kernel's diff to its own models.
@@ -113,7 +113,7 @@ the files, not its schema against your models; `diff` compares those.
113
113
 
114
114
  | Field | Default | Meaning |
115
115
  |---|---|---|
116
- | `directory` | `"migrations"` | Where revision files are read and written |
116
+ | `directory` | `"migrations"` | Where revision files are read and written; a relative path is relative to the working directory — under a kernel, start it with `%kernel.project_dir%` |
117
117
  | `version_table` / `history_table` | `"alembic_version"` / `"alembic_version_history"` | The two tables |
118
118
  | `version_table_schema` | `None` | Their schema; the connection's default when `None` |
119
119
  | `file_template` | `"{year}{month}{day}{hour}{minute}{second}_{rev}_{slug}"` | How a file is named; also `{epoch}` |
@@ -163,15 +163,19 @@ removing it undoes.
163
163
  - **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
164
164
  `postgres` for those databases; drop `console` to go without the commands; add `messenger`
165
165
  for the [message bus middleware](#message-bus-middleware).
166
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
167
+ *Environment* steps below: it lists `OrmBundle`, writes a starting `<app>/config/orm.py` with the
168
+ `default` connection, and `DATABASE_URL` in `.env`. It prints the middleware step, which a recipe
169
+ cannot make for you.
166
170
  - **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
167
171
  `xtr_orm.bundle`.
168
172
  - **Brings along** — the logging and console bundles, when those packages are installed.
169
173
  With the messenger bundle active too, the [middleware](#message-bus-middleware) are
170
174
  registered for a bus to list.
171
- - **Configure** — optional: with no configuration there is one connection, `default`, reading
172
- `DATABASE_URL`, with its revisions in `migrations` in the project directory and diffs
173
- against the models advanced-alchemy registers. Connections, replicas and table definitions go
174
- in `<app>/config/orm.py`, a `@configure` function returning `OrmConfig` — see
175
+ - **Configure** — the recipe writes a starting `<app>/config/orm.py` with the `default`
176
+ connection reading `DATABASE_URL`, its revisions in `migrations` in the project directory and
177
+ diffs against the models advanced-alchemy registers. Add connections, replicas and table
178
+ definitions to that `@configure` function returning `OrmConfig` — see
175
179
  [Kernel / bundle](#kernel--bundle).
176
180
  - **Environment** — `DATABASE_URL`, read when a connection is first used — engine, session,
177
181
  migrator or command — not when the application boots. Its query may carry options; see
@@ -367,8 +371,8 @@ MessageBusConfig(
367
371
  | Name | Class | Does | Arguments |
368
372
  |---|---|---|---|
369
373
  | `orm_transaction` | `TransactionMiddleware` | Runs every handler of a message in one transaction, committed once they all succeeded and rolled back otherwise; a failure drops the other handlers' handled stamps, so a retry runs them all again | `connection_name`, the default connection when left out |
370
- | `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection when left out |
371
- | `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error — to the application's logger when the logging bundle is active — when a handler began a transaction — `begin()`, `begin_nested()` — and left it open | `connection_names`, every connection when left out |
374
+ | `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection in use — its engine built — when left out |
375
+ | `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error — to the application's logger when the logging bundle is active — when a handler began a transaction — `begin()`, `begin_nested()` — and left it open, in a message that was handled | `connection_names`, every connection in use — its engine built — when left out |
372
376
 
373
377
  - **One session per message.** The messenger bundle makes every message a unit of work, and a
374
378
  scoped service — the session, a repository built on it — is the unit's: every handler of
@@ -424,6 +428,11 @@ Every error derives from `OrmError`.
424
428
 
425
429
  ## Known limitations
426
430
 
431
+ - **Migrations take no lock.** Two processes migrating one database at once — two deploys
432
+ starting together — both run the same revisions; run `orm:migrations:migrate` from one
433
+ place, before the application starts.
434
+ - **Applied times follow the database.** `executed_at` has a time zone on PostgreSQL and is
435
+ naive elsewhere: UTC on SQLite, the session's time zone on MySQL.
427
436
  - **advanced-alchemy's model registry is process-wide.** Its `metadata_registry` maps a
428
437
  `bind_key` to table definitions for the whole process, so two kernels in one process share
429
438
  it. Give a connection its `metadata` explicitly to keep a kernel's diff to its own models.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-orm"
3
- version = "1.5.0"
3
+ version = "3.0.0"
4
4
  description = "Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -30,7 +30,7 @@ dependencies = [
30
30
  "alembic>=1.20",
31
31
  "sqlalchemy[asyncio]>=2.0.41",
32
32
  "typing-extensions>=4.4",
33
- "xtr-logging-contracts>=1.0,<2",
33
+ "xtr-logging-contracts>=3.0,<4",
34
34
  ]
35
35
 
36
36
  [[project.authors]]
@@ -42,15 +42,18 @@ postgres = ["asyncpg>=0.29"]
42
42
  sqlite = ["aiosqlite>=0.20"]
43
43
  mysql = ["asyncmy>=0.2.9"]
44
44
  di = [
45
- "xtr-dependency-injection>=1.0,<2",
46
- "xtr-service-contracts>=1.0,<2",
45
+ "xtr-dependency-injection>=3.0,<4",
46
+ "xtr-service-contracts>=3.0,<4",
47
47
  ]
48
- console = ["xtr-console>=1.0,<2"]
49
- messenger = ["xtr-messenger>=1.0,<2"]
48
+ console = ["xtr-console>=3.0,<4"]
49
+ messenger = ["xtr-messenger>=3.0,<4"]
50
50
 
51
51
  [project.entry-points."xtr_dependency_injection.bundles"]
52
52
  orm = "xtr_orm.bundle:OrmBundle"
53
53
 
54
+ [project.entry-points.xtr_recipes]
55
+ orm = "xtr_orm.recipe"
56
+
54
57
  [dependency-groups]
55
58
  dev = [
56
59
  "basedpyright>=1.21",
@@ -60,11 +63,11 @@ dev = [
60
63
  "anyio>=4.0",
61
64
  "ty>=0.0.83",
62
65
  "aiosqlite>=0.20",
63
- "xtr-console>=1.0,<2",
64
- "xtr-dependency-injection>=1.0,<2",
65
- "xtr-logging>=1.0,<2",
66
- "xtr-messenger>=1.0,<2",
67
- "xtr-service-contracts>=1.0,<2",
66
+ "xtr-console>=3.0,<4",
67
+ "xtr-dependency-injection>=3.0,<4",
68
+ "xtr-logging>=3.0,<4",
69
+ "xtr-messenger>=3.0,<4",
70
+ "xtr-service-contracts>=3.0,<4",
68
71
  ]
69
72
 
70
73
  [build-system]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-orm"
3
- version = "1.5.0"
3
+ version = "3.0.0"
4
4
  description = "Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -26,7 +26,7 @@ dependencies = [
26
26
  "alembic>=1.20",
27
27
  "sqlalchemy[asyncio]>=2.0.41",
28
28
  "typing-extensions>=4.4",
29
- "xtr-logging-contracts>=1.0,<2",
29
+ "xtr-logging-contracts>=3.0,<4",
30
30
  ]
31
31
 
32
32
  [project.optional-dependencies]
@@ -40,14 +40,14 @@ mysql = [
40
40
  "asyncmy>=0.2.9",
41
41
  ]
42
42
  di = [
43
- "xtr-dependency-injection>=1.0,<2",
44
- "xtr-service-contracts>=1.0,<2",
43
+ "xtr-dependency-injection>=3.0,<4",
44
+ "xtr-service-contracts>=3.0,<4",
45
45
  ]
46
46
  console = [
47
- "xtr-console>=1.0,<2",
47
+ "xtr-console>=3.0,<4",
48
48
  ]
49
49
  messenger = [
50
- "xtr-messenger>=1.0,<2",
50
+ "xtr-messenger>=3.0,<4",
51
51
  ]
52
52
 
53
53
  # Advertises the bundle so debug:bundles can name it when installed but not listed.
@@ -55,6 +55,10 @@ messenger = [
55
55
  [project.entry-points."xtr_dependency_injection.bundles"]
56
56
  orm = "xtr_orm.bundle:OrmBundle"
57
57
 
58
+ # Advertises the recipe — what *Use in an application* takes — for recipes:sync.
59
+ [project.entry-points."xtr_recipes"]
60
+ orm = "xtr_orm.recipe"
61
+
58
62
  [dependency-groups]
59
63
  dev = [
60
64
  "basedpyright>=1.21",
@@ -65,11 +69,11 @@ dev = [
65
69
  "ty>=0.0.83",
66
70
  # Every test runs on SQLite files under the test's own directory: no server.
67
71
  "aiosqlite>=0.20",
68
- "xtr-console>=1.0,<2",
69
- "xtr-dependency-injection>=1.0,<2",
70
- "xtr-logging>=1.0,<2",
71
- "xtr-messenger>=1.0,<2",
72
- "xtr-service-contracts>=1.0,<2",
72
+ "xtr-console>=3.0,<4",
73
+ "xtr-dependency-injection>=3.0,<4",
74
+ "xtr-logging>=3.0,<4",
75
+ "xtr-messenger>=3.0,<4",
76
+ "xtr-service-contracts>=3.0,<4",
73
77
  ]
74
78
 
75
79
  [build-system]
@@ -0,0 +1,282 @@
1
+ ---
2
+ name: xtr-orm
3
+ description: How to configure database connections, inject async sessions and run versioned schema migrations with xtr-orm. Use when application code needs an AsyncEngine, an AsyncSession, a session per request or per message, read replicas, a DATABASE_URL, a new migration revision, a schema diff against models, the orm:migrations:* or orm:database:* console commands, one transaction per message on a bus, or when adding OrmBundle to an application on xtr-dependency-injection.
4
+ ---
5
+
6
+ # xtr-orm
7
+
8
+ A connection is configuration. You name each database once — URL, replicas, engine and session
9
+ options, where its revisions live — and the bundle hands out the engine, the session maker, the
10
+ session of the current unit of work, a `Migrator` and a `DatabaseManager`, each qualified by the
11
+ connection's name. Everything is async; there is no sync counterpart.
12
+
13
+ ## Quick reference
14
+
15
+ - Inject `Injected[AsyncSession]` for the default connection;
16
+ `Annotated[AsyncSession, Target("reports")]` for another. Never build a session yourself.
17
+ - Name databases in `<app>/config/orm.py` with a `@configure` function returning `OrmConfig`.
18
+ - Set `DATABASE_URL` with an async driver: `postgresql+asyncpg://`, `mysql+asyncmy://`,
19
+ `sqlite+aiosqlite:///`. Engine and session options may ride on its query.
20
+ - Write a revision from your models: `orm:migrations:diff --message "add invoices"`. Apply:
21
+ `orm:migrations:migrate`. Read it: `orm:migrations:status`.
22
+ - Without a container: `Migrator(engine, MigrationsConfig(directory="migrations"), Base.metadata)`.
23
+ - One transaction per message: list `orm_transaction` in the bus's `middleware`; handlers never
24
+ commit.
25
+ - On SQLite, set `MigrationsConfig(render_as_batch=True)` or table alterations cannot be written.
26
+
27
+ ## Inject a session
28
+
29
+ ```python
30
+ from typing import Annotated
31
+
32
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
33
+ from xtr_dependency_injection import Injected, Target, as_service
34
+
35
+
36
+ @as_service
37
+ class InvoiceRepository:
38
+ def __init__(self, session: Injected[AsyncSession]) -> None:
39
+ self._session = session
40
+
41
+
42
+ async def report(
43
+ session: Annotated[AsyncSession, Target("reports")],
44
+ engine: Annotated[AsyncEngine, Target("reports")],
45
+ ) -> None: ...
46
+ ```
47
+
48
+ Per connection, qualified by name — and unqualified for the default one:
49
+
50
+ | Service | What it is |
51
+ |---|---|
52
+ | `AsyncSession` | The session of the current unit of work: a request, a message, a command. Closed, and rolled back when not committed, as the unit ends |
53
+ | `AsyncEngine` | The engine on the primary database. Disposed when the container closes |
54
+ | `async_sessionmaker[AsyncSession]` | The session maker; `RoutingAsyncSessionMaker` when the connection has replicas |
55
+ | `SQLAlchemyAsyncConfig` | advanced-alchemy's configuration, for its repositories and services |
56
+ | `Migrator` / `DatabaseManager` | The connection's revisions / its database, both on the primary |
57
+ | `ConnectionRegistry` | Every connection by name, unqualified — what the commands use |
58
+
59
+ A session is scoped to the unit of work: asking for one outside any raises
60
+ `SessionUnavailableError`. Do not cache a session on a singleton.
61
+
62
+ ## Migrations
63
+
64
+ `Migrator` is the whole surface. Every method opens its own connection; `generate` reads files
65
+ only.
66
+
67
+ | Call | Does |
68
+ |---|---|
69
+ | `await migrator.status()` | `current`, `latest`, `previous`, `next`, `available`, `executed`, `new`, `executed_unavailable`, `is_up_to_date` |
70
+ | `await migrator.migrate(target)` | Runs every revision to `target`; returns `ExecutionResult`s. `plan(target)` plans it, `migrate_sql(target)` writes the SQL |
71
+ | `await migrator.execute(versions, Direction.UP)` | Exactly those revisions, nothing before or after. `plan_for_versions(versions, direction)` and `execute_sql(...)` alongside |
72
+ | `migrator.generate("message")` | Writes an empty revision after the latest (not awaited) |
73
+ | `await migrator.diff("message")` | Writes what the table definitions add; takes `allow_empty=`, `from_empty_schema=` |
74
+ | `await migrator.dump_schema("message", table_filters=...)` | A first revision creating every table an existing database has |
75
+ | `await migrator.add_versions(v)` / `add_all_versions()` | Records revisions as applied, running nothing |
76
+ | `await migrator.delete_versions(v)` / `delete_all_versions()` | Records them as not applied |
77
+ | `await migrator.rollup()` | Forgets every applied revision, then records the single remaining one |
78
+
79
+ A **target** is a revision id (full or partial), a relative one (`+1`, `-2`, `ae10+1`), or
80
+ `latest`, `first`, `next`, `prev`, `current`. A revision already applied is migrated *down* to.
81
+
82
+ Two tables, in one schema: `alembic_version` holds where the database **is** and decides what a
83
+ migration runs; `alembic_version_history` holds a row per revision ever applied, with
84
+ `executed_at` and `execution_time` in milliseconds.
85
+
86
+ `execute` is refused before touching the database unless the version table stays truthful: going
87
+ up a revision must not be applied and must follow only applied ones; going down it must be applied
88
+ and followed by none that is. **Up to date** compares the database's versions with the revision
89
+ files, not its schema with your models; `diff` does the latter.
90
+
91
+ Without a container:
92
+
93
+ ```python
94
+ from sqlalchemy.ext.asyncio import create_async_engine
95
+ from xtr_orm import MigrationsConfig, Migrator
96
+
97
+ from app.models import Base
98
+
99
+ engine = create_async_engine("sqlite+aiosqlite:///shop.db")
100
+ migrator = Migrator(
101
+ engine,
102
+ MigrationsConfig(directory="migrations", render_as_batch=True),
103
+ Base.metadata,
104
+ )
105
+
106
+ revision = await migrator.diff("add invoices") # AvailableMigration, or None with nothing to do
107
+ await migrator.migrate()
108
+ ```
109
+
110
+ `MigrationsConfig` fields you reach for most: `directory` (relative to the working directory —
111
+ under a kernel start it with `%kernel.project_dir%`), `render_as_batch` (SQLite),
112
+ `compare_type` / `compare_server_default` (off by default), `version_table` / `history_table`,
113
+ `file_template`, `post_write_hooks` (a formatter), `transaction_per_migration`,
114
+ `template_directory`, `user_module_prefix`. See [references/migrations.md](references/migrations.md)
115
+ for every field and for writing your own `script.py.mako`.
116
+
117
+ ## Console commands
118
+
119
+ Needs the `console` extra. Every command takes `--connection NAME`, defaulting to
120
+ `OrmConfig.default_connection`. The ones that change the database ask first; `-n` answers yes.
121
+
122
+ | Command | Does |
123
+ |---|---|
124
+ | `orm:migrations:diff [--message M]` | Writes what the models add; the database must be up to date first |
125
+ | `orm:migrations:migrate [VERSION]` | Runs every revision to `VERSION`, `latest` by default |
126
+ | `orm:migrations:status` / `:list` / `:current` / `:latest` | Where the database stands |
127
+ | `orm:migrations:up-to-date` | Exits non-zero when revisions are not applied — for a deploy check |
128
+ | `orm:database:create` / `:drop --force` | The database itself |
129
+ | `orm:run-sql SQL` | Runs one statement, committed |
130
+
131
+ Nine more, and every flag: [references/commands.md](references/commands.md).
132
+
133
+ Deploy order: `orm:migrations:migrate` from **one** place, before the application starts —
134
+ migrations take no lock.
135
+
136
+ Without a container, hand the commands their connections once:
137
+
138
+ ```python
139
+ from xtr_orm import ConnectionRegistry, DatabaseManager
140
+ from xtr_orm.command import use_connections
141
+
142
+ registry = ConnectionRegistry()
143
+ registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
144
+ use_connections(registry)
145
+ ```
146
+
147
+ ## Use in an application
148
+
149
+ `uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists `OrmBundle`,
150
+ writes a starting `config/orm.py` with the `default` connection, and `DATABASE_URL` in `.env`. That
151
+ is the steps below a recipe can do; the middleware it prints for you to add.
152
+
153
+ 1. **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
154
+ `postgres`; drop `console` to go without the commands; add `messenger` for the bus middleware.
155
+ 2. **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`
156
+ (`from xtr_orm.bundle import OrmBundle`).
157
+ 3. **Brings along** — the logging and console bundles when those packages are installed, each
158
+ optional. With the logging bundle active an `orm` channel is added and every migrator logs the
159
+ revisions it runs there.
160
+ 4. **Configure** — optional. With no configuration there is one connection, `default`, reading
161
+ `DATABASE_URL`, revisions in `migrations` in the project directory, diffing against the models
162
+ advanced-alchemy registers.
163
+
164
+ ```python
165
+ # <app>/config/orm.py
166
+ from xtr_dependency_injection import configure, env
167
+ from xtr_orm.bundle import ConnectionConfig, MigrationsConfig, OrmConfig
168
+
169
+ from app.models import Base
170
+
171
+
172
+ @configure
173
+ def orm() -> OrmConfig:
174
+ return OrmConfig(
175
+ connections={
176
+ "default": ConnectionConfig(
177
+ url=env("DATABASE_URL"),
178
+ replicas={"replica1": env("DATABASE_REPLICA_URL")},
179
+ engine_options={"pool_size": 10, "pool_pre_ping": True},
180
+ session_options={"expire_on_commit": False},
181
+ metadata=Base.metadata,
182
+ migrations=MigrationsConfig(directory="%kernel.project_dir%/migrations"),
183
+ ),
184
+ "reports": ConnectionConfig(url=env("REPORTS_URL"), bind_key="reports"),
185
+ },
186
+ )
187
+ ```
188
+
189
+ 5. **Environment** — `DATABASE_URL`, read when a connection is first used (engine, session,
190
+ migrator or command), not at boot. Its query may carry options — see
191
+ [references/connections.md](references/connections.md).
192
+ 6. **Use** — inject `AsyncSession`; run `orm:migrations:*` from the console.
193
+ 7. **Ignore** — nothing: commit `migrations/`.
194
+ 8. **Check** — `debug:bundles` shows `orm` as `listed` and `active`, and
195
+ `orm:migrations:status` reaches the database.
196
+ 9. **Remove** — take the `orm_*` entries out of every bus's `middleware`, drop the `BUNDLES`
197
+ entry, delete `<app>/config/orm.py`, then `uv remove xtr-orm`.
198
+
199
+ `OrmConfig` has two fields: `default_connection` (`"default"`) and `connections`
200
+ (`{"default": ConnectionConfig()}`). Two connections may not keep their revisions in one
201
+ directory; a non-default connection defaults to `%kernel.project_dir%/migrations/<name>`. Every
202
+ `ConnectionConfig` field, replicas and URL options: [references/connections.md](references/connections.md).
203
+
204
+ ## One transaction per message
205
+
206
+ With the `messenger` extra and both bundles active, three middleware are registered by name.
207
+ Nothing is added to a bus by itself — the bus lists what it wants, and order matters.
208
+
209
+ ```python
210
+ # <app>/config/messenger.py
211
+ MessageBusConfig(
212
+ middleware=[
213
+ "orm_close_connection", # outermost: closes after the transaction ended
214
+ "orm_transaction",
215
+ {"orm_transaction": {"connection_name": "reports"}},
216
+ "orm_open_transaction_logger", # inside orm_transaction
217
+ ],
218
+ )
219
+ ```
220
+
221
+ | Name | Class | Arguments |
222
+ |---|---|---|
223
+ | `orm_transaction` | `TransactionMiddleware` | `connection_name`, the default connection when left out |
224
+ | `orm_close_connection` | `CloseConnectionMiddleware` | `connection_names`, every connection in use when left out |
225
+ | `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | `connection_names`, as above |
226
+
227
+ A handler asks for `Injected[AsyncSession]` like any service and adds rows; it never commits.
228
+
229
+ - Every handler of a message shares one session per connection. A message dispatched while
230
+ another is handled joins its unit of work, in a savepoint of its own.
231
+ - A follow-up dispatched to a transport goes out at once unless stamped:
232
+ `await bus.dispatch(SendReceipt(order.id), DispatchAfterCurrentBusStamp())` sends it only after
233
+ the commit, and never after a rollback.
234
+ - Only a message a worker consumed closes connections afterwards; `sync://` closes nothing.
235
+ - Find a dropped connection with the engine option `pool_pre_ping`, not a middleware.
236
+
237
+ More, including building the middleware without a container:
238
+ [references/messenger.md](references/messenger.md).
239
+
240
+ ## Creating and dropping a database
241
+
242
+ ```python
243
+ from xtr_orm import DatabaseManager
244
+
245
+ database = DatabaseManager("postgresql+asyncpg://app:secret@db/shop")
246
+ await database.exists()
247
+ await database.create(if_not_exists=True) # True when it created one
248
+ await database.drop(if_exists=True)
249
+ database.backend, database.database, database.safe_url # properties
250
+ ```
251
+
252
+ The server is reached through its maintenance database — `postgres`, `master`, none on MySQL and
253
+ MariaDB — carrying every connection parameter of the URL but the database name. A SQLite database
254
+ is its file. Any other server raises `UnsupportedDatabaseError`.
255
+
256
+ ## Errors
257
+
258
+ Every error derives from `OrmError`.
259
+
260
+ | Error | Raised when |
261
+ |---|---|
262
+ | `InvalidArgumentError` | A configuration cannot be used: an empty name, an unknown engine/session option, a URL option that does not read. Also a `ValueError` |
263
+ | `MigrationError` | A migration is refused or revisions cannot be read: an unknown or ambiguous version, a revision out of order, a diff of a database behind its revisions |
264
+ | `UnknownConnectionError` | A connection asked for by a name nothing registered. Also a `LookupError` |
265
+ | `UnsupportedDatabaseError` | A database created or dropped on a server whose statements are not known |
266
+ | `SessionUnavailableError` | A session asked for outside a unit of work, or of a connection registered without one |
267
+
268
+ ## Do not
269
+
270
+ - Do not use a sync driver. `postgresql://` fails; write `postgresql+asyncpg://`.
271
+ - Do not commit in a handler under `orm_transaction`, and do not wrap one in
272
+ `async with session.begin()` — the transaction is already open and it is refused.
273
+ - Do not build engines or sessions by hand in application code, and do not hold a session past its
274
+ unit of work or store one on a singleton.
275
+ - Do not write an `env.py` or an `alembic.ini`; the package runs the revisions itself.
276
+ - Do not expect `up-to-date` to notice a model change — run `diff` — and do not run `diff` against
277
+ a database behind its revisions; migrate first.
278
+ - Do not leave `render_as_batch` off on SQLite.
279
+ - Do not run `orm:migrations:migrate` from several processes at once; it takes no lock.
280
+ - Do not rely on advanced-alchemy's process-wide model registry when two kernels share one
281
+ process: give each connection its `metadata`.
282
+ - Do not expect a timezone on `executed_at` outside PostgreSQL; it is naive elsewhere.
@@ -0,0 +1,67 @@
1
+ # The `orm:*` console commands
2
+
3
+ Needs the `console` extra: `uv add "xtr-orm[console]"`. With a container, the bundle declares them
4
+ when the console bundle is active, and each acts on the connections the bundle registered.
5
+
6
+ Every command takes `--connection NAME`; the default connection is used without it. The ones that
7
+ change the database ask first, and `-n` answers yes. `--message` names the revision and its file.
8
+
9
+ ## The database
10
+
11
+ | Command | Flags |
12
+ |---|---|
13
+ | `orm:database:create` | `--if-not-exists` — do nothing, successfully, when it exists |
14
+ | `orm:database:drop` | `--force` / `-f` — required, the confirmation that everything may be lost; `--if-exists` |
15
+ | `orm:run-sql SQL` | `--force-fetch` — show rows even when the statement does not look like it returns any. The statement is committed |
16
+
17
+ ## Reading where the database stands
18
+
19
+ | Command | Shows |
20
+ |---|---|
21
+ | `orm:migrations:current` | The revisions the version table holds; `base` for none |
22
+ | `orm:migrations:latest` | The revisions no other follows |
23
+ | `orm:migrations:list` | Every revision with its status, when it ran and how long it took |
24
+ | `orm:migrations:status` | The two tables, the database, previous / current / next / latest, and the counts |
25
+ | `orm:migrations:up-to-date` | Exits 1 with revisions not applied. `--fail-on-unregistered` / `-u` exits 2 for applied revisions whose file is gone; `--list-migrations` / `-l` names them |
26
+
27
+ `up-to-date` is the deploy check: it compares the database's versions with the revision files, not
28
+ its schema with your models.
29
+
30
+ ## Running revisions
31
+
32
+ | Command | Flags |
33
+ |---|---|
34
+ | `orm:migrations:migrate [VERSION]` | `VERSION` defaults to `latest`; also a full or partial id, a relative one (`+1`, `-2`), `first`, `next`, `prev`, `current`. `--dry-run` reports the plan; `--write-sql PATH` writes the SQL instead of running it; `--allow-no-migration` succeeds when there is nothing to run |
35
+ | `orm:migrations:execute VERSION...` | `--up` (the default) or `--down`; `--dry-run`; `--write-sql PATH`. Runs exactly those revisions and nothing before or after. Going up each must not be applied and must follow only applied ones; going down each must be applied and be followed by none that is |
36
+
37
+ `--write-sql` writes to the file named, or to a dated file in the directory named.
38
+
39
+ ## Writing revisions
40
+
41
+ | Command | Flags |
42
+ |---|---|
43
+ | `orm:migrations:generate` | `--message M`. An empty revision after the latest, to fill in by hand |
44
+ | `orm:migrations:diff` | `--message M`; `--allow-empty-diff` writes a revision even when nothing differs; `--from-empty-schema` writes every table as if the database were empty. The database must be at the latest revisions first |
45
+ | `orm:migrations:dump-schema` | `--message M`; `--filter-tables RE`, repeatable, dumps only the tables whose name the expression finds. For a database that predates its revisions, so there must be none yet. Written, not applied — follow with `orm:migrations:rollup` |
46
+
47
+ ## Recording without running
48
+
49
+ | Command | Flags |
50
+ |---|---|
51
+ | `orm:migrations:version [VERSION]` | Exactly one of `--add` / `--delete`; `--all` for every revision instead of one. Adding, the revision must follow only applied ones; deleting, no applied revision may follow it. An applied revision whose file is gone can always be deleted |
52
+ | `orm:migrations:rollup` | Forgets every applied revision, then records the single remaining one as all there is |
53
+
54
+ ## Without a container
55
+
56
+ ```python
57
+ from xtr_orm import ConnectionRegistry, DatabaseManager
58
+ from xtr_orm.command import use_connections
59
+
60
+ registry = ConnectionRegistry()
61
+ registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
62
+ use_connections(registry)
63
+ ```
64
+
65
+ The command classes are exported from `xtr_orm.command` — `MigrationsMigrateCommand`,
66
+ `MigrationsDiffCommand`, `DatabaseCreateCommand` and the rest — for a console application to
67
+ register itself.