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.
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/CHANGELOG.md +8 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/Cargo.lock +1 -1
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/Cargo.toml +1 -1
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/PKG-INFO +1 -1
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/connection.md +2 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/connections.md +31 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/pyproject.toml +1 -1
- ferro_orm-0.12.2/src/ferro/session.py +122 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/state.py +20 -0
- ferro_orm-0.12.2/tests/test_session.py +387 -0
- ferro_orm-0.12.1/src/ferro/session.py +0 -45
- ferro_orm-0.12.1/tests/test_session.py +0 -130
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.gitignore +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/.python-version +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/AGENTS.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/LICENSE +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/README.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/crates/ferro-migrate/Cargo.toml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/crates/ferro-migrate/src/lib.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/crates/ferro-schema-ir/Cargo.toml +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/crates/ferro-schema-ir/src/lib.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/multiple_databases.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/mutations.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/pagination.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/predicates.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/predicates_annotated.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/quickstart.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/quickstart_annotated.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/raw_sql.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/relationships.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/relationships_annotated.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/soft_deletes.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/soft_deletes_annotated.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/testing_conftest.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/timestamps.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/timestamps_annotated.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/examples/transactions.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/exceptions.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/fields.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/migrations.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/model.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/queries.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/raw-sql.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/relationships.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/api/transactions.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/changelog.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/architecture.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/backends.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/identity-map.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/performance.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/query-typing.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/concepts/type-safety.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/contributing.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/faq.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/getting-started/installation.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/getting-started/next-steps.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/getting-started/quickstart.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/migrations.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/models-and-fields.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/mutations.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/queries.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/raw-sql.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/relationships.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/guide/transactions.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/migrate-from-sqlalchemy.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/migrating-to-v0-12-0.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/multiple-databases.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/pagination.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/soft-deletes.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/testing.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/howto/timestamps.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/index.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/roadmap.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/stylesheets/extra.css +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/pages/why-ferro.md +0 -0
- {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
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
- {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
- {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
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/2026-06-19-001-ir-first-roadmap.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/ir-first-migration-guide.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/plans/ir-first-release-checklist.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/rfc/ir-contracts-v1.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/README.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/architecture-patterns/ir-first-merge-readiness-review.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/configurable-column-storage-types.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/ddl-on-live-engine.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/foreign-key-index.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/ir-invariants.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/docs/solutions/patterns/typed-null-binds.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/justfile +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/scripts/demo_queries.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/scripts/demo_queries_pre_v012.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/backend.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/codec.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/connection.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/__init__.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/_deprecations.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/base.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/composite_indexes.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/exceptions.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/fields.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/ir/__init__.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/ir/compiler.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/metaclass.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/models.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/py.typed +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/query/__init__.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/query/builder.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/raw.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/relations/__init__.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/relations/descriptors.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/hydration.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/introspect.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/lib.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/migrate.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/operations.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/query.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/schema.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/schema_bind.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/src/state.rs +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/__init__.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/conftest.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/db_backends.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/README.md +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/codec_registry_core_v1.json +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/query_user_compound_v1.json +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/shadow_reports/postgres.json +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/fixtures/shadow_reports/sqlite.json +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_bridge.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_db_type.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_nullability.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_auto_migrate.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_composite_index.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_composite_unique.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_connection.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_connection_redaction.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_constraints.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_cross_emitter_parity.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_crud.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_type_cross_emitter_parity.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_type_integration.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_type_typing.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_db_type_validation.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_deletion.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_deprecated_operator_inventory.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_deprecations.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_documentation_features.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_enum_cold_hydration.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_framework_predicates.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_helpers.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_hydration.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_ir_vectors_contract.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_metaclass_internals.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_metadata.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_migrate_plan.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_models.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_named_connections_integration.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_one_to_one.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_query_typing.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_raw_sql.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_refresh.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_relationship_engine.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_schema.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_schema_constraints.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_schema_db_type_metadata.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_shadow_fk_types.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_shadow_reports.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_string_search.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_transactions.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/tests/test_typed_null_binds.py +0 -0
- {ferro_orm-0.12.1 → ferro_orm-0.12.2}/uv.lock +0 -0
- {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
|
|
@@ -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
|
|
|
@@ -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()
|