ferro-orm 0.12.1__tar.gz → 0.12.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 (229) hide show
  1. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/CHANGELOG.md +8 -0
  2. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/Cargo.lock +1 -1
  3. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/Cargo.toml +1 -1
  4. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/PKG-INFO +1 -1
  5. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/connection.md +2 -0
  6. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/connections.md +31 -0
  7. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/pyproject.toml +1 -1
  8. ferro_orm-0.12.2/src/ferro/session.py +122 -0
  9. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/state.py +20 -0
  10. ferro_orm-0.12.2/tests/test_session.py +387 -0
  11. ferro_orm-0.12.1/src/ferro/session.py +0 -45
  12. ferro_orm-0.12.1/tests/test_session.py +0 -130
  13. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/PERMISSIONS.md +0 -0
  16. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/PYPI_CHECKLIST.md +0 -0
  17. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/PYPI_SETUP.md +0 -0
  18. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/generated/wheels.generated.yml +0 -0
  19. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/pull_request_template.md +0 -0
  20. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/ci.yml +0 -0
  21. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/packaging-smoke.yml +0 -0
  22. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/publish-docs.yml +0 -0
  23. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/publish.yml +0 -0
  24. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/release.yml +0 -0
  25. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.gitignore +0 -0
  26. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.pre-commit-config.yaml +0 -0
  27. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.python-version +0 -0
  28. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/AGENTS.md +0 -0
  29. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/CONTRIBUTING.md +0 -0
  30. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/LICENSE +0 -0
  31. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/README.md +0 -0
  32. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/crates/ferro-migrate/Cargo.toml +0 -0
  33. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/crates/ferro-migrate/src/lib.rs +0 -0
  34. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/crates/ferro-schema-ir/Cargo.toml +0 -0
  35. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/crates/ferro-schema-ir/src/lib.rs +0 -0
  36. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  37. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  38. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  39. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  40. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
  41. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/multiple_databases.py +0 -0
  42. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/mutations.py +0 -0
  43. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/pagination.py +0 -0
  44. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/predicates.py +0 -0
  45. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/predicates_annotated.py +0 -0
  46. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/quickstart.py +0 -0
  47. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/quickstart_annotated.py +0 -0
  48. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/raw_sql.py +0 -0
  49. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/relationships.py +0 -0
  50. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/relationships_annotated.py +0 -0
  51. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/soft_deletes.py +0 -0
  52. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/soft_deletes_annotated.py +0 -0
  53. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/testing_conftest.py +0 -0
  54. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/timestamps.py +0 -0
  55. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/timestamps_annotated.py +0 -0
  56. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/transactions.py +0 -0
  57. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/exceptions.md +0 -0
  58. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/fields.md +0 -0
  59. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/migrations.md +0 -0
  60. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/model.md +0 -0
  61. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/queries.md +0 -0
  62. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/raw-sql.md +0 -0
  63. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/relationships.md +0 -0
  64. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/transactions.md +0 -0
  65. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/changelog.md +0 -0
  66. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/architecture.md +0 -0
  67. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/backends.md +0 -0
  68. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/identity-map.md +0 -0
  69. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/performance.md +0 -0
  70. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/query-typing.md +0 -0
  71. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/type-safety.md +0 -0
  72. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/contributing.md +0 -0
  73. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/faq.md +0 -0
  74. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/getting-started/installation.md +0 -0
  75. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/getting-started/next-steps.md +0 -0
  76. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/getting-started/quickstart.md +0 -0
  77. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/migrations.md +0 -0
  78. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/models-and-fields.md +0 -0
  79. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/mutations.md +0 -0
  80. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/queries.md +0 -0
  81. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/raw-sql.md +0 -0
  82. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/relationships.md +0 -0
  83. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/transactions.md +0 -0
  84. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/migrate-from-sqlalchemy.md +0 -0
  85. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/migrating-to-v0-12-0.md +0 -0
  86. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/multiple-databases.md +0 -0
  87. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/pagination.md +0 -0
  88. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/soft-deletes.md +0 -0
  89. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/testing.md +0 -0
  90. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/timestamps.md +0 -0
  91. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/index.md +0 -0
  92. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/roadmap.md +0 -0
  93. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/stylesheets/extra.css +0 -0
  94. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/why-ferro.md +0 -0
  95. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  96. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  97. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  98. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  99. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  100. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  101. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +0 -0
  102. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
  103. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/ir-first-migration-guide.md +0 -0
  104. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/ir-first-release-checklist.md +0 -0
  105. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/rfc/ir-contracts-v1.md +0 -0
  106. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/README.md +0 -0
  107. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
  108. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  109. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  110. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  111. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  112. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
  113. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  114. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
  115. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  116. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  117. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
  118. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
  119. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  120. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/ir-invariants.md +0 -0
  121. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  122. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
  123. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/typed-null-binds.md +0 -0
  124. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/justfile +0 -0
  125. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/scripts/demo_queries.py +0 -0
  126. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/scripts/demo_queries_pre_v012.py +0 -0
  127. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/backend.rs +0 -0
  128. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/codec.rs +0 -0
  129. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/connection.rs +0 -0
  130. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/__init__.py +0 -0
  131. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/_annotation_utils.py +0 -0
  132. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/_core.pyi +0 -0
  133. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/_deprecations.py +0 -0
  134. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/_shadow_fk_types.py +0 -0
  135. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/base.py +0 -0
  136. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/composite_indexes.py +0 -0
  137. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/composite_uniques.py +0 -0
  138. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/exceptions.py +0 -0
  139. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/fields.py +0 -0
  140. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/ir/__init__.py +0 -0
  141. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/ir/compiler.py +0 -0
  142. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/metaclass.py +0 -0
  143. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/migrations/__init__.py +0 -0
  144. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/migrations/alembic.py +0 -0
  145. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/models.py +0 -0
  146. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/py.typed +0 -0
  147. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/query/__init__.py +0 -0
  148. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/query/builder.py +0 -0
  149. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/query/nodes.py +0 -0
  150. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/raw.py +0 -0
  151. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/relations/__init__.py +0 -0
  152. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/relations/descriptors.py +0 -0
  153. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/schema_metadata.py +0 -0
  154. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/hydration.rs +0 -0
  155. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/introspect.rs +0 -0
  156. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/lib.rs +0 -0
  157. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/migrate.rs +0 -0
  158. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/operations.rs +0 -0
  159. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/query.rs +0 -0
  160. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/schema.rs +0 -0
  161. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/schema_bind.rs +0 -0
  162. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/state.rs +0 -0
  163. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/__init__.py +0 -0
  164. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/conftest.py +0 -0
  165. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/db_backends.py +0 -0
  166. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/README.md +0 -0
  167. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
  168. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/query_user_compound_v1.json +0 -0
  169. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
  170. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
  171. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/shadow_reports/postgres.json +0 -0
  172. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/shadow_reports/sqlite.json +0 -0
  173. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_aggregation.py +0 -0
  174. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_autogenerate.py +0 -0
  175. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_bridge.py +0 -0
  176. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_db_type.py +0 -0
  177. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_nullability.py +0 -0
  178. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_type_mapping.py +0 -0
  179. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_auto_migrate.py +0 -0
  180. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_bulk_update.py +0 -0
  181. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_composite_index.py +0 -0
  182. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_composite_unique.py +0 -0
  183. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_connection.py +0 -0
  184. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_connection_redaction.py +0 -0
  185. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_constraints.py +0 -0
  186. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_cross_emitter_parity.py +0 -0
  187. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_crud.py +0 -0
  188. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_backends.py +0 -0
  189. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_type_cross_emitter_parity.py +0 -0
  190. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_type_integration.py +0 -0
  191. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_type_typing.py +0 -0
  192. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_type_validation.py +0 -0
  193. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_deletion.py +0 -0
  194. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_deprecated_operator_inventory.py +0 -0
  195. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_deprecations.py +0 -0
  196. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_docs_examples.py +0 -0
  197. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_documentation_features.py +0 -0
  198. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_enum_cold_hydration.py +0 -0
  199. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_field_wrapper.py +0 -0
  200. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_framework_predicates.py +0 -0
  201. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_helpers.py +0 -0
  202. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_hydration.py +0 -0
  203. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_ir_vectors_contract.py +0 -0
  204. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_metaclass_internals.py +0 -0
  205. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_metadata.py +0 -0
  206. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_migrate_plan.py +0 -0
  207. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_models.py +0 -0
  208. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_named_connections_integration.py +0 -0
  209. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_one_to_one.py +0 -0
  210. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_query_builder.py +0 -0
  211. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_query_typing.py +0 -0
  212. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_raw_sql.py +0 -0
  213. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_refresh.py +0 -0
  214. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_relationship_engine.py +0 -0
  215. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_schema.py +0 -0
  216. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_schema_constraints.py +0 -0
  217. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_schema_db_type_metadata.py +0 -0
  218. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_schema_enum_annotations.py +0 -0
  219. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_shadow_fk_types.py +0 -0
  220. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_shadow_reports.py +0 -0
  221. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  222. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_static_contracts.py +0 -0
  223. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_string_search.py +0 -0
  224. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_structural_types.py +0 -0
  225. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_temporal_types.py +0 -0
  226. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_transactions.py +0 -0
  227. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_typed_null_binds.py +0 -0
  228. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/uv.lock +0 -0
  229. {ferro_orm-0.12.1 → ferro_orm-0.12.2}/zensical.toml +0 -0
@@ -1,6 +1,14 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.12.2 (2026-06-23)
5
+
6
+ ### Bug Fixes
7
+
8
+ - Cross-context session teardown (Fixes #123) ([#127](https://github.com/syn54x/ferro-orm/pull/127),
9
+ [`6b9ae13`](https://github.com/syn54x/ferro-orm/commit/6b9ae1332becef88f14bdb4beae1d24e3a4104cd))
10
+
11
+
4
12
  ## v0.12.1 (2026-06-23)
5
13
 
6
14
  ### Bug Fixes
@@ -288,7 +288,7 @@ dependencies = [
288
288
 
289
289
  [[package]]
290
290
  name = "ferro"
291
- version = "0.12.1"
291
+ version = "0.12.2"
292
292
  dependencies = [
293
293
  "dashmap",
294
294
  "ferro-migrate",
@@ -3,7 +3,7 @@ members = ["crates/ferro-schema-ir", "crates/ferro-migrate"]
3
3
 
4
4
  [package]
5
5
  name = "ferro"
6
- version = "0.12.1"
6
+ version = "0.12.2"
7
7
  edition = "2024"
8
8
  readme = "README.md"
9
9
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.12.1
3
+ Version: 0.12.2
4
4
  Requires-Dist: pydantic>=2.0
5
5
  Requires-Dist: alembic>=1.18.1 ; extra == 'alembic'
6
6
  Requires-Dist: sqlalchemy>=2.0.46 ; extra == 'alembic'
@@ -2,6 +2,8 @@
2
2
 
3
3
  Functions for managing database connections and the global model registry. `connect()` registers a (optionally named) connection pool; `reset_engine()` tears everything down; the registry helpers control schema creation and the identity map. Sessionized routing is exposed via `ferro.engines.session(name)` / `ferro.Session`. See the [Connections & Databases guide](../guide/connections.md).
4
4
 
5
+ ::: ferro.Session
6
+
5
7
  ::: ferro.connect
6
8
 
7
9
  ::: ferro.PoolConfig
@@ -114,6 +114,37 @@ async with ferro.engines.session("analytics") as s:
114
114
 
115
115
  Inside an active session context, convenience APIs (`User.all()`, `User.where(...)`, `ferro.execute(...)`) automatically bind to that session's connection.
116
116
 
117
+ ### Request-scoped sessions
118
+
119
+ For short units of work (HTTP handlers, scripts, tests), use `async with`:
120
+
121
+ ```python
122
+ async with ferro.engines.session("analytics"):
123
+ rows = await User.where(lambda t: t.active == True).all() # noqa: E712
124
+ ```
125
+
126
+ ### Long-lived sessions (GUI, app lifecycle)
127
+
128
+ When startup and shutdown run in different asyncio tasks (for example Textual `on_mount` / `on_unmount`), open a session handle at startup and close it explicitly at shutdown:
129
+
130
+ ```python
131
+ class App:
132
+ async def on_mount(self) -> None:
133
+ self.session = ferro.engines.session("default")
134
+ await self.session.__aenter__()
135
+
136
+ async def on_unmount(self) -> None:
137
+ await self.session.close()
138
+ ```
139
+
140
+ `Session.close()` is safe across asyncio contexts and idempotent. Prefer explicit `session=` routing when ambient context may be unavailable in the closing task.
141
+
142
+ After a cross-context close, the enter task may still hold a stale ambient session object until a new session is opened; ambient operations then raise `RuntimeError` with a session-closed message (including when `using=` names another connection). If nested sessions are open, close inner sessions from the same task or via cross-context `close()` before closing the outer handle in the original task.
143
+
144
+ Operations on a closed session handle (ambient or explicit `session=`) raise `RuntimeError` rather than falling back to legacy default routing.
145
+
146
+ `ferro.reset_engine()` tears down **all** connections and is intended for test isolation — not per-session app lifecycle teardown.
147
+
117
148
  Legacy implicit default-connection routing (calling unqualified operations outside a session) is still temporarily supported for compatibility, but now emits a deprecation warning and is on the `v0.14.0` removal track.
118
149
  Follow [Migrating to v0.12.0](../howto/migrating-to-v0-12-0.md) to remove these legacy call sites during the compatibility window.
119
150
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.12.1"
3
+ version = "0.12.2"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,122 @@
1
+ """Session-scoped runtime state for Ferro operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextvars
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from ._core import close_session as _core_close_session
11
+ from ._core import open_session as _core_open_session
12
+ from .state import _CURRENT_SESSION
13
+
14
+ _SESSION_CLOSE_AMBIENT_MISMATCH = (
15
+ "Session close failed: ambient session does not match the closing session. "
16
+ "This usually indicates session lifecycle misuse in the current asyncio context."
17
+ )
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class Session:
22
+ connection_name: str | None = None
23
+ session_id: str | None = None
24
+ _token: Any = field(default=None, repr=False, compare=False)
25
+ _enter_context: contextvars.Context | None = field(
26
+ default=None, repr=False, compare=False
27
+ )
28
+ _enter_task: asyncio.Task[Any] | None = field(
29
+ default=None, repr=False, compare=False
30
+ )
31
+
32
+ async def __aenter__(self) -> "Session":
33
+ self.session_id, resolved_name = _core_open_session(self.connection_name)
34
+ if self.connection_name is None:
35
+ self.connection_name = resolved_name
36
+ self._token = _CURRENT_SESSION.set(self)
37
+ self._enter_context = contextvars.copy_context()
38
+ self._enter_task = asyncio.current_task()
39
+ return self
40
+
41
+ async def __aexit__(self, exc_type, exc, tb) -> None:
42
+ try:
43
+ await self.close()
44
+ except Exception as close_exc:
45
+ if exc_type is None:
46
+ raise
47
+ if exc is not None:
48
+ raise exc from close_exc
49
+ raise close_exc
50
+
51
+ async def close(self) -> None:
52
+ """Close this session and release its runtime state.
53
+
54
+ Safe to call from a different asyncio context than ``__aenter__``.
55
+ Repeated calls are no-ops.
56
+
57
+ Raises:
58
+ RuntimeError: If the ambient session in this asyncio context does not
59
+ match this handle (same-context lifecycle misuse).
60
+ """
61
+ if self.session_id is None and self._token is None:
62
+ return
63
+
64
+ self._assert_close_allowed()
65
+
66
+ if self.session_id is not None:
67
+ session_id = self.session_id
68
+ _core_close_session(session_id)
69
+ self.session_id = None
70
+
71
+ if self._token is None:
72
+ self._enter_context = None
73
+ self._enter_task = None
74
+ return
75
+
76
+ token = self._token
77
+ self._token = None
78
+ self._enter_context = None
79
+ self._enter_task = None
80
+ self._restore_ambient_session(token)
81
+
82
+ def _assert_close_allowed(self) -> None:
83
+ if self._token is None:
84
+ return
85
+ if asyncio.current_task() is not self._enter_task:
86
+ return
87
+ ambient = _CURRENT_SESSION.get()
88
+ if ambient is self:
89
+ return
90
+ entered_ambient = (
91
+ self._enter_context.get(_CURRENT_SESSION)
92
+ if self._enter_context is not None
93
+ else None
94
+ )
95
+ if entered_ambient is self and ambient is not None:
96
+ raise RuntimeError(_SESSION_CLOSE_AMBIENT_MISMATCH)
97
+
98
+ def _restore_ambient_session(self, token: Any) -> None:
99
+ ambient = _CURRENT_SESSION.get()
100
+ if ambient is not self:
101
+ try:
102
+ _CURRENT_SESSION.reset(token)
103
+ except ValueError:
104
+ return
105
+ raise RuntimeError(_SESSION_CLOSE_AMBIENT_MISMATCH)
106
+ try:
107
+ _CURRENT_SESSION.reset(token)
108
+ except ValueError:
109
+ return
110
+
111
+ def query(self, model_cls):
112
+ from .query import Query
113
+
114
+ return Query(model_cls, session=self)
115
+
116
+
117
+ class EngineManager:
118
+ def session(self, name: str | None = None) -> Session:
119
+ return Session(connection_name=name)
120
+
121
+
122
+ engines = EngineManager()
@@ -34,6 +34,16 @@ _LEGACY_DEFAULT_CONNECTION_REASON = (
34
34
  "ORM/raw operations."
35
35
  )
36
36
 
37
+ _SESSION_CLOSED_MESSAGE = (
38
+ "Session is closed. Open a new session with "
39
+ "`async with ferro.engines.session(...)` or pass an active session handle."
40
+ )
41
+
42
+
43
+ def _ensure_active_session(session: SessionLike | None) -> None:
44
+ if session is not None and session.session_id is None:
45
+ raise RuntimeError(_SESSION_CLOSED_MESSAGE)
46
+
37
47
  # Global registry for models (Python side)
38
48
  _MODEL_REGISTRY_PY = {}
39
49
 
@@ -76,6 +86,11 @@ def resolve_operation_scope(
76
86
  if explicit_session is None and using != effective_session.connection_name:
77
87
  effective_session = None
78
88
 
89
+ if explicit_session is None and ambient_session is not None:
90
+ _ensure_active_session(ambient_session)
91
+
92
+ _ensure_active_session(effective_session)
93
+
79
94
  session_id = effective_session.session_id if effective_session is not None else None
80
95
 
81
96
  if tx_id is not None:
@@ -124,6 +139,11 @@ def resolve_transaction_scope(
124
139
  if explicit_session is None and using != effective_session.connection_name:
125
140
  effective_session = None
126
141
 
142
+ if explicit_session is None and ambient_session is not None:
143
+ _ensure_active_session(ambient_session)
144
+
145
+ _ensure_active_session(effective_session)
146
+
127
147
  if parent_tx_id is not None:
128
148
  # Nested tx route is always inherited from parent.
129
149
  return parent_tx_id, None, (
@@ -0,0 +1,387 @@
1
+ import asyncio
2
+ import warnings
3
+ from contextlib import AsyncExitStack
4
+ from typing import Annotated
5
+
6
+ import pytest
7
+
8
+ import ferro
9
+
10
+
11
+ pytestmark = pytest.mark.sqlite_only
12
+
13
+
14
+ class SessionMarker(ferro.Model):
15
+ id: Annotated[int | None, ferro.FerroField(primary_key=True)] = None
16
+ label: str
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def _ensure_models_registered():
21
+ from ferro.state import _MODEL_REGISTRY_PY
22
+
23
+ SessionMarker._reregister_ferro()
24
+ _MODEL_REGISTRY_PY[SessionMarker.__name__] = SessionMarker
25
+ yield
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_session_query_api_routes_to_bound_connection(tmp_path):
30
+ app_db = tmp_path / "app.db"
31
+ analytics_db = tmp_path / "analytics.db"
32
+
33
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
34
+ await ferro.connect(f"sqlite:{analytics_db}?mode=rwc", name="analytics")
35
+ await ferro.create_tables()
36
+ await ferro.create_tables(using="analytics")
37
+
38
+ async with ferro.engines.session("analytics") as session:
39
+ created = await SessionMarker.create(id=1, label="analytics")
40
+ fetched = await session.query(SessionMarker).where(lambda t: t.id == 1).first()
41
+
42
+ assert created.id == 1
43
+ assert fetched is not None
44
+ assert fetched.label == "analytics"
45
+
46
+ with pytest.warns(
47
+ DeprecationWarning, match="Implicit default-connection routing.*v0\\.14\\.0"
48
+ ):
49
+ default_rows = await SessionMarker.all()
50
+ assert default_rows == []
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_nested_sessions_shadow_and_restore(tmp_path):
55
+ app_db = tmp_path / "app.db"
56
+ analytics_db = tmp_path / "analytics.db"
57
+
58
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
59
+ await ferro.connect(f"sqlite:{analytics_db}?mode=rwc", name="analytics")
60
+ await ferro.create_tables()
61
+ await ferro.create_tables(using="analytics")
62
+
63
+ async with ferro.engines.session("app"):
64
+ await SessionMarker.create(id=1, label="app")
65
+ async with ferro.engines.session("analytics"):
66
+ await SessionMarker.create(id=1, label="analytics")
67
+ inner_rows = await SessionMarker.all()
68
+ assert [row.label for row in inner_rows] == ["analytics"]
69
+ outer_rows = await SessionMarker.all()
70
+ assert [row.label for row in outer_rows] == ["app"]
71
+
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_explicit_session_override_beats_ambient(tmp_path):
75
+ app_db = tmp_path / "app.db"
76
+ analytics_db = tmp_path / "analytics.db"
77
+
78
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
79
+ await ferro.connect(f"sqlite:{analytics_db}?mode=rwc", name="analytics")
80
+ await ferro.create_tables()
81
+ await ferro.create_tables(using="analytics")
82
+
83
+ async with ferro.engines.session("app") as app_session:
84
+ await SessionMarker.create(id=1, label="app")
85
+ async with ferro.engines.session("analytics"):
86
+ await SessionMarker.create(id=1, label="analytics")
87
+ rows = await SessionMarker.all(session=app_session)
88
+ assert [row.label for row in rows] == ["app"]
89
+
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_unnamed_session_binds_default_connection(tmp_path):
93
+ app_db = tmp_path / "app.db"
94
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
95
+
96
+ async with ferro.engines.session() as session:
97
+ assert session.connection_name == "default"
98
+ with warnings.catch_warnings(record=True) as caught:
99
+ warnings.simplefilter("always", DeprecationWarning)
100
+ exists = await SessionMarker.where(
101
+ lambda t: t.label == "missing"
102
+ ).exists()
103
+ created = await SessionMarker.create(id=20, label="session-bound")
104
+
105
+ session_warnings = [
106
+ w
107
+ for w in caught
108
+ if issubclass(w.category, DeprecationWarning)
109
+ and "without an active session" in str(w.message)
110
+ ]
111
+ assert not session_warnings
112
+ assert exists is False
113
+ assert created.id == 20
114
+
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_legacy_unqualified_operation_warns(tmp_path):
118
+ app_db = tmp_path / "app.db"
119
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
120
+ await ferro.create_tables()
121
+
122
+ with pytest.warns(
123
+ DeprecationWarning, match="Implicit default-connection routing.*v0\\.14\\.0"
124
+ ):
125
+ created = await SessionMarker.create(id=10, label="legacy")
126
+ with pytest.warns(
127
+ DeprecationWarning, match="Implicit default-connection routing.*v0\\.14\\.0"
128
+ ):
129
+ loaded = await SessionMarker.get(10)
130
+
131
+ assert created.id == 10
132
+ assert loaded.label == "legacy"
133
+
134
+
135
+ @pytest.mark.asyncio
136
+ async def test_session_exit_from_different_asyncio_context_succeeds(tmp_path):
137
+ app_db = tmp_path / "app.db"
138
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
139
+
140
+ stack = AsyncExitStack()
141
+ await stack.__aenter__()
142
+ await stack.enter_async_context(ferro.engines.session("default"))
143
+ await SessionMarker.create(id=1, label="demo")
144
+
145
+ async def close_stack() -> None:
146
+ await stack.__aexit__(None, None, None)
147
+
148
+ await asyncio.create_task(close_stack())
149
+
150
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
151
+ await SessionMarker.all()
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_session_close_from_different_context_succeeds(tmp_path):
156
+ app_db = tmp_path / "app.db"
157
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
158
+
159
+ session = ferro.engines.session("default")
160
+ await session.__aenter__()
161
+ await SessionMarker.create(id=2, label="demo")
162
+
163
+ async def close_session() -> None:
164
+ await session.close()
165
+
166
+ await asyncio.create_task(close_session())
167
+ assert session.session_id is None
168
+
169
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
170
+ await SessionMarker.all()
171
+
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_session_close_is_idempotent(tmp_path):
175
+ app_db = tmp_path / "app.db"
176
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
177
+
178
+ session = ferro.engines.session("default")
179
+ await session.__aenter__()
180
+ await session.close()
181
+ await session.close()
182
+ assert session.session_id is None
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_nested_session_cross_context_close_inner_then_outer(tmp_path):
187
+ app_db = tmp_path / "app.db"
188
+ analytics_db = tmp_path / "analytics.db"
189
+
190
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
191
+ await ferro.connect(f"sqlite:{analytics_db}?mode=rwc", name="analytics")
192
+ await ferro.create_tables()
193
+ await ferro.create_tables(using="analytics")
194
+
195
+ outer = ferro.engines.session("app")
196
+ inner = ferro.engines.session("analytics")
197
+ await outer.__aenter__()
198
+ await SessionMarker.create(id=1, label="app")
199
+ await inner.__aenter__()
200
+ await SessionMarker.create(id=1, label="analytics")
201
+
202
+ async def close_inner() -> None:
203
+ await inner.close()
204
+
205
+ await asyncio.create_task(close_inner())
206
+
207
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
208
+ await SessionMarker.all()
209
+
210
+ rows = await SessionMarker.all(session=outer)
211
+ assert [row.label for row in rows] == ["app"]
212
+
213
+ async def close_outer() -> None:
214
+ await outer.close()
215
+
216
+ await asyncio.create_task(close_outer())
217
+ assert outer.session_id is None
218
+
219
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
220
+ await SessionMarker.all(session=outer)
221
+
222
+
223
+ @pytest.mark.asyncio
224
+ async def test_ambient_operations_after_session_close_fail_with_session_closed_error(
225
+ tmp_path,
226
+ ):
227
+ app_db = tmp_path / "app.db"
228
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
229
+
230
+ session = ferro.engines.session("default")
231
+ await session.__aenter__()
232
+ await SessionMarker.create(id=3, label="demo")
233
+
234
+ async def close_session() -> None:
235
+ await session.close()
236
+
237
+ await asyncio.create_task(close_session())
238
+
239
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
240
+ await SessionMarker.all()
241
+
242
+
243
+ @pytest.mark.asyncio
244
+ async def test_explicit_session_query_after_close_fails_with_session_closed_error(
245
+ tmp_path,
246
+ ):
247
+ app_db = tmp_path / "app.db"
248
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
249
+
250
+ session = ferro.engines.session("default")
251
+ await session.__aenter__()
252
+ await session.close()
253
+
254
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
255
+ await session.query(SessionMarker).where(lambda t: t.id == 1).first()
256
+
257
+
258
+ @pytest.mark.asyncio
259
+ async def test_same_context_exit_when_ambient_session_replaced_raises_clear_error(
260
+ tmp_path,
261
+ ):
262
+ from ferro.state import _CURRENT_SESSION
263
+
264
+ app_db = tmp_path / "app.db"
265
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
266
+ await ferro.create_tables()
267
+
268
+ outer = ferro.engines.session("app")
269
+ inner = ferro.engines.session("analytics")
270
+ await outer.__aenter__()
271
+ _CURRENT_SESSION.set(inner)
272
+
273
+ with pytest.raises(RuntimeError, match="ambient session does not match"):
274
+ await outer.close()
275
+
276
+ assert outer.session_id is not None
277
+ assert _CURRENT_SESSION.get() is inner
278
+
279
+
280
+ @pytest.mark.asyncio
281
+ async def test_same_context_outer_close_after_inner_cross_context_close_raises(
282
+ tmp_path,
283
+ ):
284
+ app_db = tmp_path / "app.db"
285
+ analytics_db = tmp_path / "analytics.db"
286
+
287
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
288
+ await ferro.connect(f"sqlite:{analytics_db}?mode=rwc", name="analytics")
289
+ await ferro.create_tables()
290
+ await ferro.create_tables(using="analytics")
291
+
292
+ outer = ferro.engines.session("app")
293
+ inner = ferro.engines.session("analytics")
294
+ await outer.__aenter__()
295
+ await inner.__aenter__()
296
+
297
+ async def close_inner() -> None:
298
+ await inner.close()
299
+
300
+ await asyncio.create_task(close_inner())
301
+
302
+ with pytest.raises(RuntimeError, match="ambient session does not match"):
303
+ await outer.close()
304
+
305
+ assert outer.session_id is not None
306
+
307
+
308
+ @pytest.mark.asyncio
309
+ async def test_stale_ambient_with_using_still_raises_session_closed(tmp_path):
310
+ app_db = tmp_path / "app.db"
311
+ analytics_db = tmp_path / "analytics.db"
312
+
313
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
314
+ await ferro.connect(f"sqlite:{analytics_db}?mode=rwc", name="analytics")
315
+ await ferro.create_tables()
316
+ await ferro.create_tables(using="analytics")
317
+
318
+ session = ferro.engines.session("app")
319
+ await session.__aenter__()
320
+
321
+ async def close_session() -> None:
322
+ await session.close()
323
+
324
+ await asyncio.create_task(close_session())
325
+
326
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
327
+ await SessionMarker.all(using="analytics")
328
+
329
+
330
+ @pytest.mark.asyncio
331
+ async def test_transaction_after_cross_context_close_raises(tmp_path):
332
+ app_db = tmp_path / "app.db"
333
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", auto_migrate=True)
334
+
335
+ session = ferro.engines.session("default")
336
+ await session.__aenter__()
337
+
338
+ async def close_session() -> None:
339
+ await session.close()
340
+
341
+ await asyncio.create_task(close_session())
342
+
343
+ with pytest.raises(RuntimeError, match="Session is closed.*Open a new session"):
344
+ async with ferro.transaction():
345
+ pass
346
+
347
+
348
+ @pytest.mark.asyncio
349
+ async def test_aexit_preserves_body_exception_when_close_fails(tmp_path):
350
+ from ferro.state import _CURRENT_SESSION
351
+
352
+ app_db = tmp_path / "app.db"
353
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
354
+ await ferro.create_tables()
355
+
356
+ outer = ferro.engines.session("app")
357
+ inner = ferro.engines.session("analytics")
358
+ await outer.__aenter__()
359
+ _CURRENT_SESSION.set(inner)
360
+
361
+ body_error = ValueError("body failed")
362
+ with pytest.raises(ValueError, match="body failed") as exc_info:
363
+ await outer.__aexit__(ValueError, body_error, None)
364
+
365
+ assert isinstance(exc_info.value.__cause__, RuntimeError)
366
+ assert "ambient session does not match" in str(exc_info.value.__cause__)
367
+
368
+
369
+ @pytest.mark.asyncio
370
+ async def test_aexit_preserves_cancelled_error_when_close_fails(tmp_path):
371
+ from ferro.state import _CURRENT_SESSION
372
+
373
+ app_db = tmp_path / "app.db"
374
+ await ferro.connect(f"sqlite:{app_db}?mode=rwc", name="app", default=True)
375
+ await ferro.create_tables()
376
+
377
+ outer = ferro.engines.session("app")
378
+ inner = ferro.engines.session("analytics")
379
+ await outer.__aenter__()
380
+ _CURRENT_SESSION.set(inner)
381
+
382
+ cancelled = asyncio.CancelledError("shutdown")
383
+ with pytest.raises(asyncio.CancelledError, match="shutdown") as exc_info:
384
+ await outer.__aexit__(asyncio.CancelledError, cancelled, None)
385
+
386
+ assert isinstance(exc_info.value.__cause__, RuntimeError)
387
+ assert "ambient session does not match" in str(exc_info.value.__cause__)
@@ -1,45 +0,0 @@
1
- """Session-scoped runtime state for Ferro operations."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from typing import Any
7
-
8
- from ._core import close_session as _core_close_session
9
- from ._core import open_session as _core_open_session
10
- from .state import _CURRENT_SESSION
11
-
12
-
13
- @dataclass(slots=True)
14
- class Session:
15
- connection_name: str | None = None
16
- session_id: str | None = None
17
- _token: Any = None
18
-
19
- async def __aenter__(self) -> "Session":
20
- self.session_id, resolved_name = _core_open_session(self.connection_name)
21
- if self.connection_name is None:
22
- self.connection_name = resolved_name
23
- self._token = _CURRENT_SESSION.set(self)
24
- return self
25
-
26
- async def __aexit__(self, exc_type, exc, tb) -> None:
27
- if self._token is not None:
28
- _CURRENT_SESSION.reset(self._token)
29
- self._token = None
30
- if self.session_id is not None:
31
- _core_close_session(self.session_id)
32
- self.session_id = None
33
-
34
- def query(self, model_cls):
35
- from .query import Query
36
-
37
- return Query(model_cls, session=self)
38
-
39
-
40
- class EngineManager:
41
- def session(self, name: str | None = None) -> Session:
42
- return Session(connection_name=name)
43
-
44
-
45
- engines = EngineManager()