ferro-orm 0.12.0__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.0 → ferro_orm-0.12.2}/AGENTS.md +20 -1
  2. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/CHANGELOG.md +17 -0
  3. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/Cargo.lock +1 -1
  4. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/Cargo.toml +1 -1
  5. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/PKG-INFO +1 -1
  6. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/connection.md +2 -0
  7. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/connections.md +31 -0
  8. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/migrating-to-v0-12-0.md +4 -0
  9. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/pyproject.toml +1 -1
  10. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/_core.pyi +1 -1
  11. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/models.py +18 -15
  12. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/relations/descriptors.py +3 -4
  13. ferro_orm-0.12.2/src/ferro/session.py +122 -0
  14. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/state.py +20 -0
  15. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/operations.rs +3 -2
  16. ferro_orm-0.12.2/tests/test_framework_predicates.py +72 -0
  17. ferro_orm-0.12.2/tests/test_session.py +387 -0
  18. ferro_orm-0.12.0/src/ferro/session.py +0 -43
  19. ferro_orm-0.12.0/tests/test_session.py +0 -104
  20. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  21. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  22. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/PERMISSIONS.md +0 -0
  23. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/PYPI_CHECKLIST.md +0 -0
  24. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/PYPI_SETUP.md +0 -0
  25. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/generated/wheels.generated.yml +0 -0
  26. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/pull_request_template.md +0 -0
  27. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/ci.yml +0 -0
  28. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/packaging-smoke.yml +0 -0
  29. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/publish-docs.yml +0 -0
  30. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/publish.yml +0 -0
  31. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.github/workflows/release.yml +0 -0
  32. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.gitignore +0 -0
  33. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.pre-commit-config.yaml +0 -0
  34. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/.python-version +0 -0
  35. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/CONTRIBUTING.md +0 -0
  36. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/LICENSE +0 -0
  37. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/README.md +0 -0
  38. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/crates/ferro-migrate/Cargo.toml +0 -0
  39. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/crates/ferro-migrate/src/lib.rs +0 -0
  40. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/crates/ferro-schema-ir/Cargo.toml +0 -0
  41. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/crates/ferro-schema-ir/src/lib.rs +0 -0
  42. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  43. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  44. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  45. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  46. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
  47. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/multiple_databases.py +0 -0
  48. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/mutations.py +0 -0
  49. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/pagination.py +0 -0
  50. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/predicates.py +0 -0
  51. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/predicates_annotated.py +0 -0
  52. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/quickstart.py +0 -0
  53. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/quickstart_annotated.py +0 -0
  54. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/raw_sql.py +0 -0
  55. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/relationships.py +0 -0
  56. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/relationships_annotated.py +0 -0
  57. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/soft_deletes.py +0 -0
  58. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/soft_deletes_annotated.py +0 -0
  59. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/testing_conftest.py +0 -0
  60. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/timestamps.py +0 -0
  61. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/timestamps_annotated.py +0 -0
  62. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/examples/transactions.py +0 -0
  63. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/exceptions.md +0 -0
  64. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/fields.md +0 -0
  65. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/migrations.md +0 -0
  66. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/model.md +0 -0
  67. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/queries.md +0 -0
  68. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/raw-sql.md +0 -0
  69. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/relationships.md +0 -0
  70. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/api/transactions.md +0 -0
  71. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/changelog.md +0 -0
  72. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/architecture.md +0 -0
  73. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/backends.md +0 -0
  74. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/identity-map.md +0 -0
  75. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/performance.md +0 -0
  76. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/query-typing.md +0 -0
  77. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/concepts/type-safety.md +0 -0
  78. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/contributing.md +0 -0
  79. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/faq.md +0 -0
  80. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/getting-started/installation.md +0 -0
  81. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/getting-started/next-steps.md +0 -0
  82. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/getting-started/quickstart.md +0 -0
  83. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/migrations.md +0 -0
  84. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/models-and-fields.md +0 -0
  85. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/mutations.md +0 -0
  86. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/queries.md +0 -0
  87. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/raw-sql.md +0 -0
  88. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/relationships.md +0 -0
  89. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/guide/transactions.md +0 -0
  90. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/migrate-from-sqlalchemy.md +0 -0
  91. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/multiple-databases.md +0 -0
  92. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/pagination.md +0 -0
  93. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/soft-deletes.md +0 -0
  94. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/testing.md +0 -0
  95. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/howto/timestamps.md +0 -0
  96. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/index.md +0 -0
  97. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/roadmap.md +0 -0
  98. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/stylesheets/extra.css +0 -0
  99. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/pages/why-ferro.md +0 -0
  100. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  101. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  102. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  103. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  104. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  105. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  106. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +0 -0
  107. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
  108. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/ir-first-migration-guide.md +0 -0
  109. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/plans/ir-first-release-checklist.md +0 -0
  110. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/rfc/ir-contracts-v1.md +0 -0
  111. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/README.md +0 -0
  112. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
  113. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  114. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  115. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  116. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  117. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
  118. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
  119. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
  120. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
  121. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  122. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
  123. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
  124. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  125. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/ir-invariants.md +0 -0
  126. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  127. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
  128. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/docs/solutions/patterns/typed-null-binds.md +0 -0
  129. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/justfile +0 -0
  130. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/scripts/demo_queries.py +0 -0
  131. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/scripts/demo_queries_pre_v012.py +0 -0
  132. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/backend.rs +0 -0
  133. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/codec.rs +0 -0
  134. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/connection.rs +0 -0
  135. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/__init__.py +0 -0
  136. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/_annotation_utils.py +0 -0
  137. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/_deprecations.py +0 -0
  138. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/_shadow_fk_types.py +0 -0
  139. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/base.py +0 -0
  140. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/composite_indexes.py +0 -0
  141. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/composite_uniques.py +0 -0
  142. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/exceptions.py +0 -0
  143. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/fields.py +0 -0
  144. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/ir/__init__.py +0 -0
  145. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/ir/compiler.py +0 -0
  146. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/metaclass.py +0 -0
  147. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/migrations/__init__.py +0 -0
  148. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/migrations/alembic.py +0 -0
  149. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/py.typed +0 -0
  150. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/query/__init__.py +0 -0
  151. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/query/builder.py +0 -0
  152. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/query/nodes.py +0 -0
  153. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/raw.py +0 -0
  154. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/relations/__init__.py +0 -0
  155. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/ferro/schema_metadata.py +0 -0
  156. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/hydration.rs +0 -0
  157. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/introspect.rs +0 -0
  158. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/lib.rs +0 -0
  159. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/migrate.rs +0 -0
  160. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/query.rs +0 -0
  161. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/schema.rs +0 -0
  162. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/schema_bind.rs +0 -0
  163. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/src/state.rs +0 -0
  164. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/__init__.py +0 -0
  165. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/conftest.py +0 -0
  166. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/db_backends.py +0 -0
  167. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/README.md +0 -0
  168. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
  169. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/query_user_compound_v1.json +0 -0
  170. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
  171. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
  172. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/shadow_reports/postgres.json +0 -0
  173. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/fixtures/shadow_reports/sqlite.json +0 -0
  174. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_aggregation.py +0 -0
  175. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_autogenerate.py +0 -0
  176. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_bridge.py +0 -0
  177. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_db_type.py +0 -0
  178. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_nullability.py +0 -0
  179. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_alembic_type_mapping.py +0 -0
  180. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_auto_migrate.py +0 -0
  181. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_bulk_update.py +0 -0
  182. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_composite_index.py +0 -0
  183. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_composite_unique.py +0 -0
  184. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_connection.py +0 -0
  185. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_connection_redaction.py +0 -0
  186. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_constraints.py +0 -0
  187. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_cross_emitter_parity.py +0 -0
  188. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_crud.py +0 -0
  189. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_backends.py +0 -0
  190. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_type_cross_emitter_parity.py +0 -0
  191. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_type_integration.py +0 -0
  192. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_type_typing.py +0 -0
  193. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_db_type_validation.py +0 -0
  194. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_deletion.py +0 -0
  195. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_deprecated_operator_inventory.py +0 -0
  196. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_deprecations.py +0 -0
  197. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_docs_examples.py +0 -0
  198. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_documentation_features.py +0 -0
  199. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_enum_cold_hydration.py +0 -0
  200. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_field_wrapper.py +0 -0
  201. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_helpers.py +0 -0
  202. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_hydration.py +0 -0
  203. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_ir_vectors_contract.py +0 -0
  204. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_metaclass_internals.py +0 -0
  205. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_metadata.py +0 -0
  206. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_migrate_plan.py +0 -0
  207. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_models.py +0 -0
  208. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_named_connections_integration.py +0 -0
  209. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_one_to_one.py +0 -0
  210. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_query_builder.py +0 -0
  211. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_query_typing.py +0 -0
  212. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_raw_sql.py +0 -0
  213. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_refresh.py +0 -0
  214. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_relationship_engine.py +0 -0
  215. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_schema.py +0 -0
  216. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_schema_constraints.py +0 -0
  217. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_schema_db_type_metadata.py +0 -0
  218. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_schema_enum_annotations.py +0 -0
  219. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_shadow_fk_types.py +0 -0
  220. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_shadow_reports.py +0 -0
  221. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  222. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_static_contracts.py +0 -0
  223. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_string_search.py +0 -0
  224. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_structural_types.py +0 -0
  225. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_temporal_types.py +0 -0
  226. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_transactions.py +0 -0
  227. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/tests/test_typed_null_binds.py +0 -0
  228. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/uv.lock +0 -0
  229. {ferro_orm-0.12.0 → ferro_orm-0.12.2}/zensical.toml +0 -0
@@ -90,7 +90,8 @@ If you add a new schema feature (e.g. partial indexes, exclusion constraints):
90
90
  numbered list above.
91
91
  2. Implement it in **both** the Alembic and Rust paths in the same PR.
92
92
  3. Add a parity test that asserts the names match.
93
- 4. Add a regression entry to `CHANGELOG.md`.
93
+ 4. Do not edit `CHANGELOG.md` manually — release tooling records entries at
94
+ release time (see I-11).
94
95
 
95
96
  ---
96
97
 
@@ -276,3 +277,21 @@ Rules:
276
277
  updates are complete before merge.
277
278
  - AI agents and human contributors follow the same requirement. If issue status
278
279
  closure is missing, the PR is not done.
280
+
281
+ ---
282
+
283
+ ## I-11: Do not edit CHANGELOG.md manually
284
+
285
+ `CHANGELOG.md` is updated automatically by the release workflow (semantic
286
+ release / release tooling). Agents and contributors must **not** add,
287
+ reorder, or edit changelog entries in feature, bugfix, or docs PRs.
288
+
289
+ What to do instead:
290
+
291
+ - Describe user-visible changes in the PR title and body.
292
+ - Rely on conventional commit messages and the release process to populate
293
+ `CHANGELOG.md` after merge.
294
+
295
+ If release tooling fails to capture a change, fix the release configuration or
296
+ commit message conventions — do not patch `CHANGELOG.md` by hand in ordinary
297
+ PRs.
@@ -1,6 +1,23 @@
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
+
12
+ ## v0.12.1 (2026-06-23)
13
+
14
+ ### Bug Fixes
15
+
16
+ - Stop framework predicate warnings and bind unnamed sessions to default
17
+ ([#124](https://github.com/syn54x/ferro-orm/pull/124),
18
+ [`66b92d8`](https://github.com/syn54x/ferro-orm/commit/66b92d8b691a1ad806691b9841bcbaf93c78f691))
19
+
20
+
4
21
  ## v0.12.0 (2026-06-23)
5
22
 
6
23
  ### Documentation
@@ -288,7 +288,7 @@ dependencies = [
288
288
 
289
289
  [[package]]
290
290
  name = "ferro"
291
- version = "0.12.0"
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.0"
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.0
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
 
@@ -72,6 +72,10 @@ active session relied on implicit default-connection routing. That fallback is
72
72
  deprecated. Wrap request- or task-scoped work in a session so routing, the
73
73
  identity map, and transactions are explicit and isolated under concurrency.
74
74
 
75
+ After a single unnamed `connect(dsn)`, `async with ferro.engines.session()` (no
76
+ argument) binds to that default connection — you do not need to pass
77
+ `"default"` explicitly.
78
+
75
79
  === "Before (deprecated)"
76
80
 
77
81
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.12.0"
3
+ version = "0.12.2"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -160,7 +160,7 @@ async def begin_transaction(
160
160
  async def commit_transaction(tx_id: str, session_id: Optional[str] = None) -> None: ...
161
161
  def transaction_connection_name(tx_id: str, session_id: Optional[str] = None) -> str: ...
162
162
  async def rollback_transaction(tx_id: str, session_id: Optional[str] = None) -> None: ...
163
- def open_session(using: Optional[str] = None) -> str: ...
163
+ def open_session(using: Optional[str] = None) -> tuple[str, str]: ...
164
164
  def close_session(session_id: str) -> None: ...
165
165
  async def raw_execute(
166
166
  sql: str,
@@ -31,7 +31,7 @@ from ._core import (
31
31
  from .base import ForeignKey, foreign_key_allows_none
32
32
  from .exceptions import ModelDoesNotExist
33
33
  from .metaclass import ModelMetaclass
34
- from .query import Query, QueryNode
34
+ from .query import Predicate, Query, QueryNode
35
35
  from .state import (
36
36
  _CURRENT_TRANSACTION,
37
37
  _CURRENT_TRANSACTION_CONNECTION,
@@ -43,6 +43,11 @@ from .state import (
43
43
  _FERRO_CONNECTION_ATTR = "__ferro_connection_name"
44
44
 
45
45
 
46
+ def _field_eq(field_name: str, value: Any) -> Predicate[Any]:
47
+ """Build a lambda predicate for dynamic field equality without operator style."""
48
+ return lambda t, f=field_name, v=value: getattr(t, f) == v
49
+
50
+
46
51
  def _transaction_or_using(
47
52
  using: str | None, session: "Session | None"
48
53
  ) -> tuple[str | None, str | None, str | None]:
@@ -289,12 +294,10 @@ class Model(BaseModel, metaclass=ModelMetaclass):
289
294
 
290
295
  if pk_val is not None:
291
296
  name = self.__class__.__name__
292
- query = self.__class__.where(
293
- getattr(self.__class__, pk_field_name) == pk_val
294
- )
297
+ query = self.__class__.where(_field_eq(pk_field_name, pk_val))
295
298
  if operation_using is not None:
296
299
  query = Query(self.__class__, using=operation_using).where(
297
- getattr(self.__class__, pk_field_name) == pk_val
300
+ _field_eq(pk_field_name, pk_val)
298
301
  )
299
302
  await query.delete()
300
303
  evict_instance(
@@ -391,7 +394,9 @@ class Model(BaseModel, metaclass=ModelMetaclass):
391
394
  if pk_field_name is None:
392
395
  raise RuntimeError(f"Model {cls.__name__} does not define a primary key")
393
396
 
394
- instance = await cls.where(getattr(cls, pk_field_name) == pk, session=session).first()
397
+ instance = await cls.where(
398
+ _field_eq(pk_field_name, pk), session=session
399
+ ).first()
395
400
  if instance:
396
401
  cls._fix_types(instance)
397
402
  return instance
@@ -423,10 +428,10 @@ class Model(BaseModel, metaclass=ModelMetaclass):
423
428
  )
424
429
 
425
430
  evict_instance(name, str(pk_val), identity_using, session_id=session_id)
426
- query = self.__class__.where(getattr(self.__class__, pk_field_name) == pk_val)
431
+ query = self.__class__.where(_field_eq(pk_field_name, pk_val))
427
432
  if operation_using is not None:
428
433
  query = Query(self.__class__, using=operation_using).where(
429
- getattr(self.__class__, pk_field_name) == pk_val
434
+ _field_eq(pk_field_name, pk_val)
430
435
  )
431
436
  fresh_instance = await query.first()
432
437
 
@@ -576,7 +581,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
576
581
  """
577
582
  query = Query(cls, session=session)
578
583
  for key, val in fields.items():
579
- query = query.where(getattr(cls, key) == val)
584
+ query = query.where(_field_eq(key, val))
580
585
 
581
586
  instance = await query.first()
582
587
  if instance:
@@ -604,7 +609,7 @@ class Model(BaseModel, metaclass=ModelMetaclass):
604
609
  """
605
610
  query = Query(cls, session=session)
606
611
  for key, val in fields.items():
607
- query = query.where(getattr(cls, key) == val)
612
+ query = query.where(_field_eq(key, val))
608
613
 
609
614
  instance = await query.first()
610
615
  if instance:
@@ -662,9 +667,7 @@ class ModelConnection[M: Model]:
662
667
  f"Model {self.model_cls.__name__} does not define a primary key"
663
668
  )
664
669
 
665
- instance = await self.where(
666
- getattr(self.model_cls, pk_field_name) == pk
667
- ).first()
670
+ instance = await self.where(_field_eq(pk_field_name, pk)).first()
668
671
  if instance:
669
672
  self.model_cls._fix_types(instance)
670
673
  return instance
@@ -677,7 +680,7 @@ class ModelConnection[M: Model]:
677
680
  ) -> tuple[M, bool]:
678
681
  query = Query(self.model_cls, using=self._connection_name)
679
682
  for key, val in fields.items():
680
- query = query.where(getattr(self.model_cls, key) == val)
683
+ query = query.where(_field_eq(key, val))
681
684
 
682
685
  instance = await query.first()
683
686
  if instance:
@@ -691,7 +694,7 @@ class ModelConnection[M: Model]:
691
694
  ) -> tuple[M, bool]:
692
695
  query = Query(self.model_cls, using=self._connection_name)
693
696
  for key, val in fields.items():
694
- query = query.where(getattr(self.model_cls, key) == val)
697
+ query = query.where(_field_eq(key, val))
695
698
 
696
699
  instance = await query.first()
697
700
  if instance:
@@ -69,9 +69,10 @@ class RelationshipDescriptor(BaseModel):
69
69
 
70
70
  pk_val = getattr(instance, pk_field)
71
71
 
72
+ fk_field = f"{self.field_name}_id"
72
73
  if self.is_one_to_one:
73
74
  return self._target_model.where(
74
- getattr(self._target_model, f"{self.field_name}_id") == pk_val
75
+ lambda t, f=fk_field, v=pk_val: getattr(t, f) == v
75
76
  ).first()
76
77
 
77
78
  from ..query.builder import Relation
@@ -79,9 +80,7 @@ class RelationshipDescriptor(BaseModel):
79
80
  return Relation(
80
81
  self._target_model,
81
82
  using=_instance_origin_outside_transaction(instance),
82
- ).where(
83
- getattr(self._target_model, f"{self.field_name}_id") == pk_val
84
- )
83
+ ).where(lambda t, f=fk_field, v=pk_val: getattr(t, f) == v)
85
84
 
86
85
 
87
86
  class ForwardDescriptor(BaseModel):
@@ -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, (
@@ -825,9 +825,10 @@ pub fn rollback_transaction(
825
825
 
826
826
  #[pyfunction]
827
827
  #[pyo3(signature = (using=None))]
828
- pub fn open_session(using: Option<String>) -> PyResult<String> {
828
+ pub fn open_session(using: Option<String>) -> PyResult<(String, String)> {
829
829
  let (connection_name, _) = active_connection_for_route(using)?;
830
- Ok(register_session(connection_name))
830
+ let session_id = register_session(connection_name.clone());
831
+ Ok((session_id, connection_name))
831
832
  }
832
833
 
833
834
  #[pyfunction]
@@ -0,0 +1,72 @@
1
+ """Regression tests for framework-internal predicate style (issue #121)."""
2
+
3
+ import warnings
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 FrameworkPredicateMarker(ferro.Model):
15
+ id: Annotated[int | None, ferro.FerroField(primary_key=True)] = None
16
+ name: str
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def _ensure_models_registered():
21
+ from ferro.state import _MODEL_REGISTRY_PY
22
+
23
+ FrameworkPredicateMarker._reregister_ferro()
24
+ _MODEL_REGISTRY_PY[FrameworkPredicateMarker.__name__] = FrameworkPredicateMarker
25
+ yield
26
+
27
+
28
+ def _operator_predicate_warnings(caught: list[warnings.WarningMessage]) -> list[str]:
29
+ return [
30
+ str(w.message)
31
+ for w in caught
32
+ if issubclass(w.category, DeprecationWarning)
33
+ and "Operator predicate style" in str(w.message)
34
+ ]
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_framework_helpers_do_not_emit_operator_predicate_warnings(tmp_path):
39
+ db = tmp_path / "framework_predicates.db"
40
+ await ferro.connect(f"sqlite:{db}?mode=rwc", auto_migrate=True)
41
+
42
+ async with ferro.engines.session("default"):
43
+ widget = FrameworkPredicateMarker(id=1, name="demo")
44
+ await widget.save()
45
+
46
+ with warnings.catch_warnings(record=True) as caught:
47
+ warnings.simplefilter("always", DeprecationWarning)
48
+ loaded = await FrameworkPredicateMarker.get_or_none(1)
49
+ assert loaded is not None
50
+ await loaded.refresh()
51
+ found, created = await FrameworkPredicateMarker.get_or_create(name="demo")
52
+ assert found is not None
53
+ assert created is False
54
+ await loaded.delete()
55
+
56
+ assert _operator_predicate_warnings(caught) == []
57
+
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_model_connection_get_or_none_does_not_emit_operator_warnings(tmp_path):
61
+ db = tmp_path / "framework_predicates_bound.db"
62
+ await ferro.connect(f"sqlite:{db}?mode=rwc", auto_migrate=True)
63
+
64
+ async with ferro.engines.session("default"):
65
+ await FrameworkPredicateMarker.create(id=2, name="bound")
66
+
67
+ with warnings.catch_warnings(record=True) as caught:
68
+ warnings.simplefilter("always", DeprecationWarning)
69
+ loaded = await FrameworkPredicateMarker.using("default").get_or_none(2)
70
+
71
+ assert loaded is not None
72
+ assert _operator_predicate_warnings(caught) == []