xtr-orm 3.0.0__tar.gz → 4.0.2__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 (69) hide show
  1. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/LICENSE +4 -0
  2. xtr_orm-3.0.0/README.md → xtr_orm-4.0.2/PKG-INFO +127 -3
  3. xtr_orm-3.0.0/PKG-INFO → xtr_orm-4.0.2/README.md +88 -42
  4. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/pyproject.toml +11 -11
  5. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/pyproject.toml.orig +11 -11
  6. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/.agents/skills/xtr-orm/SKILL.md +146 -5
  7. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/.agents/skills/xtr-orm/references/commands.md +8 -3
  8. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/__init__.py +16 -1
  9. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/bundle/__init__.py +9 -1
  10. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/bundle/connection_config.py +32 -2
  11. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/bundle/orm_bundle.py +16 -8
  12. xtr_orm-4.0.2/src/xtr_orm/bundle/orm_mappings_pass.py +210 -0
  13. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/__init__.py +7 -6
  14. xtr_orm-3.0.0/src/xtr_orm/command/connection_command.py → xtr_orm-4.0.2/src/xtr_orm/command/_reporting.py +16 -58
  15. xtr_orm-4.0.2/src/xtr_orm/command/connection_command.py +66 -0
  16. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_current_command.py +2 -1
  17. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_diff_command.py +2 -1
  18. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_dump_schema_command.py +2 -1
  19. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_execute_command.py +2 -8
  20. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_generate_command.py +2 -1
  21. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_latest_command.py +2 -1
  22. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_migrate_command.py +2 -2
  23. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_up_to_date_command.py +2 -1
  24. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/connection_registry.py +8 -1
  25. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/database/database_manager.py +18 -3
  26. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/exception/__init__.py +2 -0
  27. xtr_orm-4.0.2/src/xtr_orm/exception/missing_database_error.py +29 -0
  28. xtr_orm-4.0.2/src/xtr_orm/mapping_driver_interface.py +38 -0
  29. xtr_orm-3.0.0/src/xtr_orm/command/connections.py +0 -37
  30. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/.agents/skills/xtr-orm/references/connections.md +0 -0
  31. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/.agents/skills/xtr-orm/references/messenger.md +0 -0
  32. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/.agents/skills/xtr-orm/references/migrations.md +0 -0
  33. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/bundle/orm_config.py +0 -0
  34. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/database_create_command.py +0 -0
  35. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/database_drop_command.py +0 -0
  36. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_list_command.py +0 -0
  37. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_rollup_command.py +0 -0
  38. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_status_command.py +0 -0
  39. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/migrations_version_command.py +0 -0
  40. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/command/run_sql_command.py +0 -0
  41. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/database/__init__.py +0 -0
  42. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/exception/invalid_argument_error.py +0 -0
  43. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/exception/migration_error.py +0 -0
  44. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/exception/orm_error.py +0 -0
  45. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/exception/session_unavailable_error.py +0 -0
  46. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/exception/unknown_connection_error.py +0 -0
  47. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/exception/unsupported_database_error.py +0 -0
  48. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/messenger/__init__.py +0 -0
  49. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/messenger/close_connection_middleware.py +0 -0
  50. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/messenger/open_transaction_logger_middleware.py +0 -0
  51. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/messenger/transaction_middleware.py +0 -0
  52. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/__init__.py +0 -0
  53. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/_environment.py +0 -0
  54. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/_history.py +0 -0
  55. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/_revisions.py +0 -0
  56. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/_schema.py +0 -0
  57. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/available_migration.py +0 -0
  58. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/direction.py +0 -0
  59. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/executed_migration.py +0 -0
  60. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/execution_result.py +0 -0
  61. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/migration_plan.py +0 -0
  62. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/migration_status.py +0 -0
  63. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/migrations_config.py +0 -0
  64. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/migrator.py +0 -0
  65. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/migrations/template/script.py.mako +0 -0
  66. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/py.typed +0 -0
  67. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/recipe/__init__.py +0 -0
  68. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/recipe/files/config/orm.py.tmpl +0 -0
  69. {xtr_orm-3.0.0 → xtr_orm-4.0.2}/src/xtr_orm/recipe/manifest.toml +0 -0
@@ -1,6 +1,10 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 xterr
4
+ Copyright (c) 2006-2018 Doctrine Project
5
+ Copyright (c) 2011 Fabien Potencier, Doctrine Project
6
+ Copyright (c) 2006-2013 Doctrine Project
7
+ Copyright (c) 2004-present Fabien Potencier
4
8
 
5
9
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
10
  of this software and associated documentation files (the "Software"), to deal
@@ -1,3 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-orm
3
+ Version: 4.0.2
4
+ Summary: Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection.
5
+ Keywords: orm,database,migrations,sqlalchemy,alembic,asyncio
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Database
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: advanced-alchemy>=1.11
20
+ Requires-Dist: alembic>=1.20
21
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.41
22
+ Requires-Dist: typing-extensions>=4.4
23
+ Requires-Dist: xtr-logging-contracts>=4.0,<5
24
+ Requires-Dist: xtr-console>=4.0,<5 ; extra == 'console'
25
+ Requires-Dist: xtr-dependency-injection>=4.0,<5 ; extra == 'di'
26
+ Requires-Dist: xtr-service-contracts>=4.0,<5 ; extra == 'di'
27
+ Requires-Dist: xtr-messenger>=4.0,<5 ; extra == 'messenger'
28
+ Requires-Dist: asyncmy>=0.2.9 ; extra == 'mysql'
29
+ Requires-Dist: asyncpg>=0.29 ; extra == 'postgres'
30
+ Requires-Dist: aiosqlite>=0.20 ; extra == 'sqlite'
31
+ Requires-Python: >=3.11
32
+ Provides-Extra: console
33
+ Provides-Extra: di
34
+ Provides-Extra: messenger
35
+ Provides-Extra: mysql
36
+ Provides-Extra: postgres
37
+ Provides-Extra: sqlite
38
+ Description-Content-Type: text/markdown
39
+
1
40
  <div align="center">
2
41
 
3
42
  # xtr-orm
@@ -299,6 +338,82 @@ whatever the one before wrote. Migrations and the database commands always use t
299
338
  A block can be sent elsewhere with advanced-alchemy's `primary_context()` and
300
339
  `replica_context()`, from `advanced_alchemy.routing`.
301
340
 
341
+ ## Mapping tables from a bundle
342
+
343
+ A library can bring its own tables to an application's database — tables whose prefix, names
344
+ or column types the application configures, and which therefore exist only once that
345
+ configuration is read. `OrmMappingsPass` puts them into the table definitions of the
346
+ connection the application picked, while the container is compiled: early enough for
347
+ `orm:migrations:diff` to write them like any of the application's own models.
348
+
349
+ The library's bundle keeps the pass, registers it in `build`, and hands over the driver it
350
+ builds from its configuration in `load_extension`:
351
+
352
+ ```python
353
+ from xtr_orm.bundle import OrmBundle, OrmMappingsPass
354
+
355
+
356
+ @final
357
+ @required_bundle(OrmBundle)
358
+ @as_bundle("invoicing", config=InvoicingConfig)
359
+ class InvoicingBundle(Bundle[InvoicingConfig]):
360
+ def __init__(self) -> None:
361
+ self._mappings = OrmMappingsPass("invoicing.connection", "invoicing.persist")
362
+
363
+ @override
364
+ def build(self, builder: ContainerBuilder) -> None:
365
+ builder.add_compiler_pass(self._mappings)
366
+
367
+ @override
368
+ def load_extension(
369
+ self, config: InvoicingConfig, services: ServiceConfigurator, builder: ContainerBuilder
370
+ ) -> None:
371
+ builder.set_parameter("invoicing.connection", config.connection)
372
+ builder.set_parameter("invoicing.persist", config.persist)
373
+ if config.persist:
374
+ self._mappings.set_driver(InvoicingDriver(config.table_prefix))
375
+ ```
376
+
377
+ A driver is anything satisfying `MappingDriverInterface`: one call, adding its tables and
378
+ naming them. Subclass it to have the type checker hold you to that call.
379
+
380
+ ```python
381
+ from xtr_orm import MappingDriverInterface
382
+
383
+
384
+ @final
385
+ class InvoicingDriver(MappingDriverInterface):
386
+ def __init__(self, prefix: str) -> None:
387
+ self._prefix = prefix
388
+
389
+ def load_metadata(self, metadata: MetaData) -> tuple[str, ...]:
390
+ name = f"{self._prefix}invoice"
391
+ if name in metadata.tables:
392
+ return ()
393
+ _ = Table(name, metadata, Column("id", Integer, primary_key=True))
394
+ return (name,)
395
+ ```
396
+
397
+ - **Which definitions** — the ones the connection's migrations compare the database with: its
398
+ `metadata` when it has one, else the ones advanced-alchemy registers for its `bind_key`. A
399
+ connection keeping several sets is refused, naming it; give that one a single `MetaData`.
400
+ - **Idempotent, always** — `load_metadata` skips the tables already there and leaves them out
401
+ of what it returns. The registered definitions are process-wide — advanced-alchemy keeps one
402
+ `MetaData` per bind key for the whole process — so two kernels in one process load the same
403
+ driver into the same one. That outlives the flag: a kernel built with the library's
404
+ persistence off maps nothing itself, yet still *sees* every table a persisting kernel of the
405
+ same process put in that bind key, or in a module-level `MetaData` both reach, and diffs
406
+ them as its own. Give such a kernel a `bind_key`, or a freshly built `MetaData`, that no
407
+ persisting kernel used.
408
+ - **On or off** — the flag parameter is read for truth, and an unset one means off, so a
409
+ library whose persistence an application turned off maps nothing. On with no driver handed
410
+ over is refused, as is a connection name nothing configured — both naming what is missing.
411
+ - **Read while compiling** — both parameters are read before `%name%` references are resolved
412
+ and long before an `env(...)` one is, so a bundle sets them from its own resolved
413
+ configuration: a plain name and a plain flag. One still holding a `%name%` reference is
414
+ refused, naming the parameter, as is a connection parameter holding something that is not a
415
+ name at all — a name is never coerced out of it, since a connection may be called `7`.
416
+
302
417
  ## Console commands
303
418
 
304
419
  Every command takes `--connection NAME`; the default connection's is used without it. The
@@ -324,15 +439,18 @@ commands that change the database ask first; `-n` answers yes.
324
439
 
325
440
  `--write-sql` writes to the file named, or to a dated file in the directory named.
326
441
 
327
- Without a container, give the commands their connections once:
442
+ Every command takes the `ConnectionRegistry` it acts on as its one constructor argument — the
443
+ bundle's with a container, and your own without one. Nothing stands in for it: a console with no
444
+ container to supply it reports the parameter it cannot fill.
328
445
 
329
446
  ```python
330
447
  from xtr_orm import ConnectionRegistry, DatabaseManager
331
- from xtr_orm.command import use_connections
448
+ from xtr_orm.command import MigrationsMigrateCommand
332
449
 
333
450
  registry = ConnectionRegistry()
334
451
  registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
335
- use_connections(registry)
452
+
453
+ migrate = MigrationsMigrateCommand(registry)
336
454
  ```
337
455
 
338
456
  ## Message bus middleware
@@ -414,6 +532,11 @@ every statement committed as it runs, and with every connection parameter of the
414
532
  database: the credentials, the host and the driver's query options, such as `ssl`. A SQLite
415
533
  database is its file. Any other server is refused with `UnsupportedDatabaseError`.
416
534
 
535
+ `create` and `drop` let the server refuse a database that is already there or is not, which is
536
+ why both take a flag — `create(if_not_exists=True)`, `drop(if_exists=True)` — to be told so
537
+ instead. On SQLite the file is the database: dropping one that is not there raises
538
+ `MissingDatabaseError`.
539
+
417
540
  ## Errors
418
541
 
419
542
  Every error derives from `OrmError`.
@@ -422,6 +545,7 @@ Every error derives from `OrmError`.
422
545
  |---|---|
423
546
  | `InvalidArgumentError` | A configuration cannot be used — an empty name, an unknown option, a URL option that does not read (also a `ValueError`) |
424
547
  | `MigrationError` | A migration is refused or its revisions cannot be read — an unknown or ambiguous version, a revision out of order, a diff of a database behind its revisions |
548
+ | `MissingDatabaseError` | A database is dropped that is not there — a SQLite file that does not exist — without `if_exists` (also a `FileNotFoundError`) |
425
549
  | `UnknownConnectionError` | A connection is asked for by a name nothing registered (also a `LookupError`) |
426
550
  | `UnsupportedDatabaseError` | A database is created or dropped on a server whose statements are not known |
427
551
  | `SessionUnavailableError` | A connection's session is asked for outside a unit of work, or of a connection registered without one |
@@ -1,42 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: xtr-orm
3
- Version: 3.0.0
4
- Summary: Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection.
5
- Keywords: orm,database,migrations,sqlalchemy,alembic,asyncio
6
- Author: Xterr
7
- Author-email: Xterr <me@xterr.dev>
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Classifier: Development Status :: 3 - Alpha
11
- Classifier: Framework :: AsyncIO
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Classifier: Programming Language :: Python :: 3.14
17
- Classifier: Topic :: Database
18
- Classifier: Typing :: Typed
19
- Requires-Dist: advanced-alchemy>=1.11
20
- Requires-Dist: alembic>=1.20
21
- Requires-Dist: sqlalchemy[asyncio]>=2.0.41
22
- Requires-Dist: typing-extensions>=4.4
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
- Requires-Dist: asyncmy>=0.2.9 ; extra == 'mysql'
29
- Requires-Dist: asyncpg>=0.29 ; extra == 'postgres'
30
- Requires-Dist: aiosqlite>=0.20 ; extra == 'sqlite'
31
- Requires-Python: >=3.11
32
- Provides-Extra: console
33
- Provides-Extra: di
34
- Provides-Extra: messenger
35
- Provides-Extra: mysql
36
- Provides-Extra: postgres
37
- Provides-Extra: sqlite
38
- Description-Content-Type: text/markdown
39
-
40
1
  <div align="center">
41
2
 
42
3
  # xtr-orm
@@ -338,6 +299,82 @@ whatever the one before wrote. Migrations and the database commands always use t
338
299
  A block can be sent elsewhere with advanced-alchemy's `primary_context()` and
339
300
  `replica_context()`, from `advanced_alchemy.routing`.
340
301
 
302
+ ## Mapping tables from a bundle
303
+
304
+ A library can bring its own tables to an application's database — tables whose prefix, names
305
+ or column types the application configures, and which therefore exist only once that
306
+ configuration is read. `OrmMappingsPass` puts them into the table definitions of the
307
+ connection the application picked, while the container is compiled: early enough for
308
+ `orm:migrations:diff` to write them like any of the application's own models.
309
+
310
+ The library's bundle keeps the pass, registers it in `build`, and hands over the driver it
311
+ builds from its configuration in `load_extension`:
312
+
313
+ ```python
314
+ from xtr_orm.bundle import OrmBundle, OrmMappingsPass
315
+
316
+
317
+ @final
318
+ @required_bundle(OrmBundle)
319
+ @as_bundle("invoicing", config=InvoicingConfig)
320
+ class InvoicingBundle(Bundle[InvoicingConfig]):
321
+ def __init__(self) -> None:
322
+ self._mappings = OrmMappingsPass("invoicing.connection", "invoicing.persist")
323
+
324
+ @override
325
+ def build(self, builder: ContainerBuilder) -> None:
326
+ builder.add_compiler_pass(self._mappings)
327
+
328
+ @override
329
+ def load_extension(
330
+ self, config: InvoicingConfig, services: ServiceConfigurator, builder: ContainerBuilder
331
+ ) -> None:
332
+ builder.set_parameter("invoicing.connection", config.connection)
333
+ builder.set_parameter("invoicing.persist", config.persist)
334
+ if config.persist:
335
+ self._mappings.set_driver(InvoicingDriver(config.table_prefix))
336
+ ```
337
+
338
+ A driver is anything satisfying `MappingDriverInterface`: one call, adding its tables and
339
+ naming them. Subclass it to have the type checker hold you to that call.
340
+
341
+ ```python
342
+ from xtr_orm import MappingDriverInterface
343
+
344
+
345
+ @final
346
+ class InvoicingDriver(MappingDriverInterface):
347
+ def __init__(self, prefix: str) -> None:
348
+ self._prefix = prefix
349
+
350
+ def load_metadata(self, metadata: MetaData) -> tuple[str, ...]:
351
+ name = f"{self._prefix}invoice"
352
+ if name in metadata.tables:
353
+ return ()
354
+ _ = Table(name, metadata, Column("id", Integer, primary_key=True))
355
+ return (name,)
356
+ ```
357
+
358
+ - **Which definitions** — the ones the connection's migrations compare the database with: its
359
+ `metadata` when it has one, else the ones advanced-alchemy registers for its `bind_key`. A
360
+ connection keeping several sets is refused, naming it; give that one a single `MetaData`.
361
+ - **Idempotent, always** — `load_metadata` skips the tables already there and leaves them out
362
+ of what it returns. The registered definitions are process-wide — advanced-alchemy keeps one
363
+ `MetaData` per bind key for the whole process — so two kernels in one process load the same
364
+ driver into the same one. That outlives the flag: a kernel built with the library's
365
+ persistence off maps nothing itself, yet still *sees* every table a persisting kernel of the
366
+ same process put in that bind key, or in a module-level `MetaData` both reach, and diffs
367
+ them as its own. Give such a kernel a `bind_key`, or a freshly built `MetaData`, that no
368
+ persisting kernel used.
369
+ - **On or off** — the flag parameter is read for truth, and an unset one means off, so a
370
+ library whose persistence an application turned off maps nothing. On with no driver handed
371
+ over is refused, as is a connection name nothing configured — both naming what is missing.
372
+ - **Read while compiling** — both parameters are read before `%name%` references are resolved
373
+ and long before an `env(...)` one is, so a bundle sets them from its own resolved
374
+ configuration: a plain name and a plain flag. One still holding a `%name%` reference is
375
+ refused, naming the parameter, as is a connection parameter holding something that is not a
376
+ name at all — a name is never coerced out of it, since a connection may be called `7`.
377
+
341
378
  ## Console commands
342
379
 
343
380
  Every command takes `--connection NAME`; the default connection's is used without it. The
@@ -363,15 +400,18 @@ commands that change the database ask first; `-n` answers yes.
363
400
 
364
401
  `--write-sql` writes to the file named, or to a dated file in the directory named.
365
402
 
366
- Without a container, give the commands their connections once:
403
+ Every command takes the `ConnectionRegistry` it acts on as its one constructor argument — the
404
+ bundle's with a container, and your own without one. Nothing stands in for it: a console with no
405
+ container to supply it reports the parameter it cannot fill.
367
406
 
368
407
  ```python
369
408
  from xtr_orm import ConnectionRegistry, DatabaseManager
370
- from xtr_orm.command import use_connections
409
+ from xtr_orm.command import MigrationsMigrateCommand
371
410
 
372
411
  registry = ConnectionRegistry()
373
412
  registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
374
- use_connections(registry)
413
+
414
+ migrate = MigrationsMigrateCommand(registry)
375
415
  ```
376
416
 
377
417
  ## Message bus middleware
@@ -453,6 +493,11 @@ every statement committed as it runs, and with every connection parameter of the
453
493
  database: the credentials, the host and the driver's query options, such as `ssl`. A SQLite
454
494
  database is its file. Any other server is refused with `UnsupportedDatabaseError`.
455
495
 
496
+ `create` and `drop` let the server refuse a database that is already there or is not, which is
497
+ why both take a flag — `create(if_not_exists=True)`, `drop(if_exists=True)` — to be told so
498
+ instead. On SQLite the file is the database: dropping one that is not there raises
499
+ `MissingDatabaseError`.
500
+
456
501
  ## Errors
457
502
 
458
503
  Every error derives from `OrmError`.
@@ -461,6 +506,7 @@ Every error derives from `OrmError`.
461
506
  |---|---|
462
507
  | `InvalidArgumentError` | A configuration cannot be used — an empty name, an unknown option, a URL option that does not read (also a `ValueError`) |
463
508
  | `MigrationError` | A migration is refused or its revisions cannot be read — an unknown or ambiguous version, a revision out of order, a diff of a database behind its revisions |
509
+ | `MissingDatabaseError` | A database is dropped that is not there — a SQLite file that does not exist — without `if_exists` (also a `FileNotFoundError`) |
464
510
  | `UnknownConnectionError` | A connection is asked for by a name nothing registered (also a `LookupError`) |
465
511
  | `UnsupportedDatabaseError` | A database is created or dropped on a server whose statements are not known |
466
512
  | `SessionUnavailableError` | A connection's session is asked for outside a unit of work, or of a connection registered without one |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-orm"
3
- version = "3.0.0"
3
+ version = "4.0.2"
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>=3.0,<4",
33
+ "xtr-logging-contracts>=4.0,<5",
34
34
  ]
35
35
 
36
36
  [[project.authors]]
@@ -42,11 +42,11 @@ postgres = ["asyncpg>=0.29"]
42
42
  sqlite = ["aiosqlite>=0.20"]
43
43
  mysql = ["asyncmy>=0.2.9"]
44
44
  di = [
45
- "xtr-dependency-injection>=3.0,<4",
46
- "xtr-service-contracts>=3.0,<4",
45
+ "xtr-dependency-injection>=4.0,<5",
46
+ "xtr-service-contracts>=4.0,<5",
47
47
  ]
48
- console = ["xtr-console>=3.0,<4"]
49
- messenger = ["xtr-messenger>=3.0,<4"]
48
+ console = ["xtr-console>=4.0,<5"]
49
+ messenger = ["xtr-messenger>=4.0,<5"]
50
50
 
51
51
  [project.entry-points."xtr_dependency_injection.bundles"]
52
52
  orm = "xtr_orm.bundle:OrmBundle"
@@ -63,11 +63,11 @@ dev = [
63
63
  "anyio>=4.0",
64
64
  "ty>=0.0.83",
65
65
  "aiosqlite>=0.20",
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",
66
+ "xtr-console>=4.0,<5",
67
+ "xtr-dependency-injection>=4.0,<5",
68
+ "xtr-logging>=4.0,<5",
69
+ "xtr-messenger>=4.0,<5",
70
+ "xtr-service-contracts>=4.0,<5",
71
71
  ]
72
72
 
73
73
  [build-system]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-orm"
3
- version = "3.0.0"
3
+ version = "4.0.2"
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>=3.0,<4",
29
+ "xtr-logging-contracts>=4.0,<5",
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>=3.0,<4",
44
- "xtr-service-contracts>=3.0,<4",
43
+ "xtr-dependency-injection>=4.0,<5",
44
+ "xtr-service-contracts>=4.0,<5",
45
45
  ]
46
46
  console = [
47
- "xtr-console>=3.0,<4",
47
+ "xtr-console>=4.0,<5",
48
48
  ]
49
49
  messenger = [
50
- "xtr-messenger>=3.0,<4",
50
+ "xtr-messenger>=4.0,<5",
51
51
  ]
52
52
 
53
53
  # Advertises the bundle so debug:bundles can name it when installed but not listed.
@@ -69,11 +69,11 @@ dev = [
69
69
  "ty>=0.0.83",
70
70
  # Every test runs on SQLite files under the test's own directory: no server.
71
71
  "aiosqlite>=0.20",
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",
72
+ "xtr-console>=4.0,<5",
73
+ "xtr-dependency-injection>=4.0,<5",
74
+ "xtr-logging>=4.0,<5",
75
+ "xtr-messenger>=4.0,<5",
76
+ "xtr-service-contracts>=4.0,<5",
77
77
  ]
78
78
 
79
79
  [build-system]
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
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, when a library bundle must add tables it builds in code to a connection's definitions, or when adding OrmBundle to an application on xtr-dependency-injection.
4
4
  ---
5
5
 
6
6
  # xtr-orm
@@ -20,6 +20,8 @@ connection's name. Everything is async; there is no sync counterpart.
20
20
  - Write a revision from your models: `orm:migrations:diff --message "add invoices"`. Apply:
21
21
  `orm:migrations:migrate`. Read it: `orm:migrations:status`.
22
22
  - Without a container: `Migrator(engine, MigrationsConfig(directory="migrations"), Base.metadata)`.
23
+ - A library bringing its own tables registers an `OrmMappingsPass` from its bundle's `build`; its
24
+ driver's `load_metadata` must be idempotent.
23
25
  - One transaction per message: list `orm_transaction` in the bus's `middleware`; handlers never
24
26
  commit.
25
27
  - On SQLite, set `MigrationsConfig(render_as_batch=True)` or table alterations cannot be written.
@@ -114,6 +116,75 @@ under a kernel start it with `%kernel.project_dir%`), `render_as_batch` (SQLite)
114
116
  `template_directory`, `user_module_prefix`. See [references/migrations.md](references/migrations.md)
115
117
  for every field and for writing your own `script.py.mako`.
116
118
 
119
+ ## Map tables from a bundle
120
+
121
+ For a library whose own tables an application configures — their prefix, which of them it keeps,
122
+ which column type an id gets — so they exist only once that configuration is read. Its bundle
123
+ keeps an `OrmMappingsPass`, registers it in `build`, and hands it the driver it builds in
124
+ `load_extension`; the pass loads the tables into the definitions the named connection's
125
+ migrations use, so `orm:migrations:diff` writes them like the application's own models.
126
+
127
+ ```python
128
+ from typing import final
129
+
130
+ from sqlalchemy import Column, Integer, MetaData, Table
131
+ from typing_extensions import override
132
+ from xtr_dependency_injection import (
133
+ Bundle,
134
+ ContainerBuilder,
135
+ ServiceConfigurator,
136
+ as_bundle,
137
+ required_bundle,
138
+ )
139
+ from xtr_orm import MappingDriverInterface
140
+ from xtr_orm.bundle import OrmBundle, OrmMappingsPass
141
+
142
+
143
+ @final
144
+ @required_bundle(OrmBundle)
145
+ @as_bundle("invoicing", config=InvoicingConfig)
146
+ class InvoicingBundle(Bundle[InvoicingConfig]):
147
+ def __init__(self) -> None:
148
+ self._mappings = OrmMappingsPass("invoicing.connection", "invoicing.persist")
149
+
150
+ @override
151
+ def build(self, builder: ContainerBuilder) -> None:
152
+ builder.add_compiler_pass(self._mappings)
153
+
154
+ @override
155
+ def load_extension(
156
+ self, config: InvoicingConfig, services: ServiceConfigurator, builder: ContainerBuilder
157
+ ) -> None:
158
+ builder.set_parameter("invoicing.connection", config.connection)
159
+ builder.set_parameter("invoicing.persist", config.persist)
160
+ if config.persist:
161
+ self._mappings.set_driver(InvoicingDriver(config.table_prefix))
162
+
163
+
164
+ @final
165
+ class InvoicingDriver(MappingDriverInterface):
166
+ def __init__(self, prefix: str) -> None:
167
+ self._prefix = prefix
168
+
169
+ def load_metadata(self, metadata: MetaData) -> tuple[str, ...]:
170
+ name = f"{self._prefix}invoice"
171
+ if name in metadata.tables:
172
+ return ()
173
+ _ = Table(name, metadata, Column("id", Integer, primary_key=True))
174
+ return (name,)
175
+ ```
176
+
177
+ A driver is anything satisfying `MappingDriverInterface`: `load_metadata(metadata)` adds its
178
+ tables and returns the names it added. Subclassing it has the type checker hold you to that call.
179
+
180
+ | Rule | What it means |
181
+ |---|---|
182
+ | Idempotent | Skip every name already in `metadata.tables` and leave it out of the return. The registered definitions are process-wide — one `MetaData` per bind key for the whole process — so a second kernel loads the same driver into the same one. It outlives the flag: a kernel built with persistence off maps nothing itself, yet still sees and diffs every table a persisting kernel of the same process put in that bind key, or in a module-level `MetaData` both reach. Give such a kernel a `bind_key`, or a freshly built `MetaData`, no persisting kernel used |
183
+ | Which definitions | The connection's `metadata` when it has one, else advanced-alchemy's for its `bind_key`. A connection keeping several sets is refused, naming it |
184
+ | On or off | The flag parameter is read for truth; unset means off. On with no driver, or a connection name nothing configured, fails the build with `InvalidArgumentError` |
185
+ | Plain values | Both parameters are read while compiling, before `%name%` is resolved and long before `env(...)` is — set them from the bundle's own resolved config. One still holding a `%name%` reference fails the build naming the parameter, as does a connection parameter holding something that is not a name at all: a name is never coerced out of it, a connection being allowed to be called `7` |
186
+ | Required peer | The bundle must declare `@required_bundle(OrmBundle)`; without it the pass cannot read the orm's configuration and the build fails with `UnknownConfigTypeError` |
187
+
117
188
  ## Console commands
118
189
 
119
190
  Needs the `console` extra. Every command takes `--connection NAME`, defaulting to
@@ -133,15 +204,18 @@ Nine more, and every flag: [references/commands.md](references/commands.md).
133
204
  Deploy order: `orm:migrations:migrate` from **one** place, before the application starts —
134
205
  migrations take no lock.
135
206
 
136
- Without a container, hand the commands their connections once:
207
+ Every command takes the `ConnectionRegistry` it acts on as its one constructor argument. Nothing
208
+ stands in for it: a console with no container to supply it raises `MissingContainerError` naming
209
+ `connections`.
137
210
 
138
211
  ```python
139
212
  from xtr_orm import ConnectionRegistry, DatabaseManager
140
- from xtr_orm.command import use_connections
213
+ from xtr_orm.command import MigrationsMigrateCommand
141
214
 
142
215
  registry = ConnectionRegistry()
143
216
  registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
144
- use_connections(registry)
217
+
218
+ migrate = MigrationsMigrateCommand(registry)
145
219
  ```
146
220
 
147
221
  ## Use in an application
@@ -251,7 +325,68 @@ database.backend, database.database, database.safe_url # properties
251
325
 
252
326
  The server is reached through its maintenance database — `postgres`, `master`, none on MySQL and
253
327
  MariaDB — carrying every connection parameter of the URL but the database name. A SQLite database
254
- is its file. Any other server raises `UnsupportedDatabaseError`.
328
+ is its file. Without `if_exists`, dropping a SQLite file that is not there raises
329
+ `MissingDatabaseError`. Any other server raises `UnsupportedDatabaseError`.
330
+
331
+ ## Testing
332
+
333
+ Test against a SQLite file in the test's own directory: it is a real database, needs no server,
334
+ and goes away with `tmp_path`. Give a `Migrator` the engine, a `MigrationsConfig` pointing at a
335
+ directory of its own, and your table definitions.
336
+
337
+ ```python
338
+ # tests/test_invoices.py
339
+ from pathlib import Path
340
+
341
+ import pytest
342
+ from sqlalchemy import text
343
+ from sqlalchemy.ext.asyncio import create_async_engine
344
+ from xtr_orm import DatabaseManager, MigrationsConfig, Migrator
345
+
346
+ from app.models import Base
347
+
348
+
349
+ @pytest.fixture
350
+ def anyio_backend() -> str:
351
+ return "asyncio"
352
+
353
+
354
+ @pytest.mark.anyio
355
+ async def test_the_schema_the_models_describe_is_migrated(tmp_path: Path) -> None:
356
+ url = f"sqlite+aiosqlite:///{tmp_path / 'app.sqlite'}"
357
+ assert await DatabaseManager(url).create()
358
+ engine = create_async_engine(url)
359
+ migrator = Migrator(
360
+ engine,
361
+ # render_as_batch: SQLite cannot alter a table any other way.
362
+ MigrationsConfig(directory=str(tmp_path / "migrations"), render_as_batch=True),
363
+ Base.metadata,
364
+ )
365
+ try:
366
+ assert await migrator.diff("the catalogue") is not None
367
+ _ = await migrator.migrate()
368
+
369
+ assert (await migrator.status()).is_up_to_date
370
+ async with engine.connect() as connection:
371
+ _ = await connection.execute(text("select count(*) from invoice"))
372
+ finally:
373
+ await engine.dispose()
374
+ ```
375
+
376
+ - **A command** takes a `ConnectionRegistry`, so build one for the test and hand it over:
377
+ `MigrationsStatusCommand(registry)`. Register the connection with the engine, migrator and
378
+ `DatabaseManager` the test made.
379
+ - **A unit of work** — the scope an `AsyncSession` lives in — is opened by
380
+ `bind_callable(container, work, per_call_scope=True)` under a kernel; outside one, ask the
381
+ registry for a session only where you registered one, or `SessionUnavailableError` says so.
382
+ - **Two kernels in one process** share advanced-alchemy's model registry, so give each
383
+ connection its `metadata` rather than let a diff pick up another test's tables.
384
+ - **A mapping driver** needs no container: `driver.load_metadata(MetaData())` returns the names
385
+ it added, and a second call on the same `MetaData` must return `()`. A kernel test asserting
386
+ a library mapped nothing needs a bind key of its own, or an earlier kernel's tables are still
387
+ in the one it diffs.
388
+ - **Dispose every engine you build**, in a fixture's teardown: an engine left open holds the
389
+ SQLite file and warns when the loop closes.
255
390
 
256
391
  ## Errors
257
392
 
@@ -261,6 +396,7 @@ Every error derives from `OrmError`.
261
396
  |---|---|
262
397
  | `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
398
  | `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 |
399
+ | `MissingDatabaseError` | A database dropped without `if_exists` is not there — a SQLite file that does not exist. Also a `FileNotFoundError` |
264
400
  | `UnknownConnectionError` | A connection asked for by a name nothing registered. Also a `LookupError` |
265
401
  | `UnsupportedDatabaseError` | A database created or dropped on a server whose statements are not known |
266
402
  | `SessionUnavailableError` | A session asked for outside a unit of work, or of a connection registered without one |
@@ -279,4 +415,9 @@ Every error derives from `OrmError`.
279
415
  - Do not run `orm:migrations:migrate` from several processes at once; it takes no lock.
280
416
  - Do not rely on advanced-alchemy's process-wide model registry when two kernels share one
281
417
  process: give each connection its `metadata`.
418
+ - Do not write a mapping driver that defines a table unconditionally: a `load_metadata` that
419
+ does not skip what is already there fails the second kernel of the process.
420
+ - Do not read turning a library's persistence off as a clean slate: that kernel maps nothing of
421
+ its own, but still sees and diffs the tables a persisting kernel of the same process left in
422
+ the bind key — give it a `bind_key`, or a freshly built `MetaData`, no persisting kernel used.
282
423
  - Do not expect a timezone on `executed_at` outside PostgreSQL; it is naive elsewhere.