python-ddd-framework 0.3.1__py3-none-any.whl
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.
- python_ddd_framework/__init__.py +449 -0
- python_ddd_framework/application/__init__.py +6 -0
- python_ddd_framework/application/build_spec.py +37 -0
- python_ddd_framework/application/builder.py +260 -0
- python_ddd_framework/application/composition.py +219 -0
- python_ddd_framework/application/runtime.py +549 -0
- python_ddd_framework/application/runtime_hooks.py +327 -0
- python_ddd_framework/application/state.py +216 -0
- python_ddd_framework/application_services/__init__.py +54 -0
- python_ddd_framework/application_services/catalog.py +500 -0
- python_ddd_framework/application_services/contracts.py +190 -0
- python_ddd_framework/application_services/dispatcher.py +423 -0
- python_ddd_framework/application_services/errors.py +89 -0
- python_ddd_framework/application_services/execution.py +199 -0
- python_ddd_framework/application_services/interceptors.py +44 -0
- python_ddd_framework/application_services/invocation.py +431 -0
- python_ddd_framework/application_services/policies.py +46 -0
- python_ddd_framework/application_services/seeding.py +72 -0
- python_ddd_framework/application_services/signature.py +49 -0
- python_ddd_framework/application_services/validation.py +84 -0
- python_ddd_framework/auditing/__init__.py +12 -0
- python_ddd_framework/auditing/contracts.py +39 -0
- python_ddd_framework/auditing/control.py +48 -0
- python_ddd_framework/auditing/sqlalchemy/__init__.py +6 -0
- python_ddd_framework/auditing/sqlalchemy/migrations/0001_auditing.py +45 -0
- python_ddd_framework/auditing/sqlalchemy/migrations/__init__.py +1 -0
- python_ddd_framework/auditing/sqlalchemy/models.py +31 -0
- python_ddd_framework/auditing/sqlalchemy/module.py +32 -0
- python_ddd_framework/auditing/sqlalchemy/store.py +63 -0
- python_ddd_framework/authorization/__init__.py +38 -0
- python_ddd_framework/authorization/catalog.py +63 -0
- python_ddd_framework/authorization/contracts.py +241 -0
- python_ddd_framework/authorization/definition_discovery.py +81 -0
- python_ddd_framework/authorization/definitions.py +43 -0
- python_ddd_framework/authorization/errors.py +40 -0
- python_ddd_framework/background_execution/__init__.py +17 -0
- python_ddd_framework/background_execution/application.py +41 -0
- python_ddd_framework/background_execution/child.py +123 -0
- python_ddd_framework/background_execution/contracts.py +48 -0
- python_ddd_framework/background_execution/lifecycle.py +22 -0
- python_ddd_framework/background_execution/local.py +127 -0
- python_ddd_framework/background_execution/locks.py +33 -0
- python_ddd_framework/background_execution/management.py +17 -0
- python_ddd_framework/background_execution/module.py +10 -0
- python_ddd_framework/background_execution/permissions.py +14 -0
- python_ddd_framework/background_execution/processes.py +331 -0
- python_ddd_framework/background_jobs/__init__.py +31 -0
- python_ddd_framework/background_jobs/catalog.py +340 -0
- python_ddd_framework/background_jobs/contracts.py +208 -0
- python_ddd_framework/background_jobs/declaration.py +67 -0
- python_ddd_framework/background_jobs/errors.py +28 -0
- python_ddd_framework/background_jobs/execution.py +203 -0
- python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
- python_ddd_framework/background_jobs/pgqueuer/__init__.py +6 -0
- python_ddd_framework/background_jobs/pgqueuer/enqueue.py +86 -0
- python_ddd_framework/background_jobs/pgqueuer/migrations/0001_pgqueuer_1_3_2.py +95 -0
- python_ddd_framework/background_jobs/pgqueuer/migrations/__init__.py +1 -0
- python_ddd_framework/background_jobs/pgqueuer/module.py +106 -0
- python_ddd_framework/background_jobs/pgqueuer/options.py +82 -0
- python_ddd_framework/background_jobs/pgqueuer/runtime.py +479 -0
- python_ddd_framework/background_jobs/pgqueuer/sql/pgqueuer_1_3_2_install.sql +118 -0
- python_ddd_framework/background_jobs/pgqueuer/supervision.py +201 -0
- python_ddd_framework/background_workers/__init__.py +22 -0
- python_ddd_framework/background_workers/catalog.py +118 -0
- python_ddd_framework/background_workers/contracts.py +152 -0
- python_ddd_framework/background_workers/errors.py +20 -0
- python_ddd_framework/background_workers/execution.py +133 -0
- python_ddd_framework/background_workers/runtime.py +219 -0
- python_ddd_framework/caching/__init__.py +11 -0
- python_ddd_framework/caching/catalog.py +73 -0
- python_ddd_framework/caching/contracts.py +73 -0
- python_ddd_framework/caching/errors.py +20 -0
- python_ddd_framework/cli/__init__.py +105 -0
- python_ddd_framework/cli/development.py +85 -0
- python_ddd_framework/cli/errors.py +5 -0
- python_ddd_framework/cli/inspection.py +96 -0
- python_ddd_framework/cli/project.py +69 -0
- python_ddd_framework/cli/runtime.py +56 -0
- python_ddd_framework/configuration/__init__.py +21 -0
- python_ddd_framework/configuration/composition.py +65 -0
- python_ddd_framework/configuration/contracts.py +68 -0
- python_ddd_framework/configuration/dotenv_source.py +51 -0
- python_ddd_framework/configuration/environment_source.py +38 -0
- python_ddd_framework/configuration/immutability.py +124 -0
- python_ddd_framework/configuration/input_shape.py +58 -0
- python_ddd_framework/configuration/merge.py +103 -0
- python_ddd_framework/configuration/root.py +215 -0
- python_ddd_framework/configuration/sources.py +519 -0
- python_ddd_framework/configuration/values.py +427 -0
- python_ddd_framework/configuration/yaml_source.py +51 -0
- python_ddd_framework/developer_kit/__init__.py +1 -0
- python_ddd_framework/developer_kit/generation.py +182 -0
- python_ddd_framework/developer_kit/project_metadata.py +45 -0
- python_ddd_framework/developer_kit/source.py +53 -0
- python_ddd_framework/developer_kit/templates/module/cookiecutter.json +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +70 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +10 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +24 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration.py.jinja +41 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +20 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options.py.jinja +7 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +83 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +84 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +10 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +35 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +10 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/orders.py.jinja +56 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +14 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +19 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings.py.jinja +16 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +24 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +7 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions.py.jinja +15 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/files.py.jinja +73 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +25 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +36 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/__init__.py.jinja +7 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/orders.py.jinja +25 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +26 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/orders.py.jinja +49 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +18 -0
- python_ddd_framework/developer_kit/templates/project/cookiecutter.json +6 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/.dockerignore +9 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/.gitignore +6 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/.python-version +1 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +38 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/Dockerfile +22 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +130 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/app.development.yaml.jinja +30 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.dev.yaml.jinja +24 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.production.yaml.jinja +20 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +84 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +185 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/pyproject.toml.jinja +28 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/__init__.py.jinja +1 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/main.py.jinja +47 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/module.py.jinja +37 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/conftest.py.jinja +8 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/host/test_http.py.jinja +57 -0
- python_ddd_framework/developer_kit/wiring.py +162 -0
- python_ddd_framework/diagnostics/__init__.py +23 -0
- python_ddd_framework/diagnostics/journal.py +174 -0
- python_ddd_framework/diagnostics/model.py +83 -0
- python_ddd_framework/diagnostics/source.py +25 -0
- python_ddd_framework/distributed_lock/__init__.py +6 -0
- python_ddd_framework/distributed_lock/contracts.py +20 -0
- python_ddd_framework/distributed_lock/options.py +19 -0
- python_ddd_framework/domain/__init__.py +13 -0
- python_ddd_framework/domain/aggregates.py +64 -0
- python_ddd_framework/domain/entities.py +33 -0
- python_ddd_framework/domain/errors.py +12 -0
- python_ddd_framework/domain/value_objects.py +31 -0
- python_ddd_framework/errors/__init__.py +75 -0
- python_ddd_framework/errors/base.py +13 -0
- python_ddd_framework/errors/business.py +53 -0
- python_ddd_framework/errors/configuration.py +119 -0
- python_ddd_framework/errors/diagnostics.py +12 -0
- python_ddd_framework/errors/lifecycle.py +164 -0
- python_ddd_framework/errors/modularity.py +100 -0
- python_ddd_framework/errors/services.py +100 -0
- python_ddd_framework/events/__init__.py +25 -0
- python_ddd_framework/events/aggregate.py +39 -0
- python_ddd_framework/events/catalog.py +136 -0
- python_ddd_framework/events/contracts.py +39 -0
- python_ddd_framework/events/contribution.py +128 -0
- python_ddd_framework/events/discovery.py +105 -0
- python_ddd_framework/events/errors.py +48 -0
- python_ddd_framework/events/runtime.py +192 -0
- python_ddd_framework/fastapi/__init__.py +49 -0
- python_ddd_framework/fastapi/action.py +175 -0
- python_ddd_framework/fastapi/adapter.py +391 -0
- python_ddd_framework/fastapi/application_services.py +654 -0
- python_ddd_framework/fastapi/background.py +42 -0
- python_ddd_framework/fastapi/contracts.py +275 -0
- python_ddd_framework/fastapi/errors.py +32 -0
- python_ddd_framework/fastapi/filters.py +111 -0
- python_ddd_framework/fastapi/health.py +44 -0
- python_ddd_framework/fastapi/http.py +72 -0
- python_ddd_framework/fastapi/http_router.py +216 -0
- python_ddd_framework/fastapi/manual_action.py +105 -0
- python_ddd_framework/fastapi/middleware.py +93 -0
- python_ddd_framework/fastapi/parameters.py +97 -0
- python_ddd_framework/fastapi/realtime/__init__.py +13 -0
- python_ddd_framework/fastapi/realtime/authentication.py +181 -0
- python_ddd_framework/fastapi/realtime/connection.py +166 -0
- python_ddd_framework/fastapi/realtime/module.py +41 -0
- python_ddd_framework/fastapi/realtime/options.py +50 -0
- python_ddd_framework/fastapi/realtime/runtime.py +423 -0
- python_ddd_framework/fastapi/request_context.py +247 -0
- python_ddd_framework/fastapi/route_integrity.py +88 -0
- python_ddd_framework/fastapi/routing.py +424 -0
- python_ddd_framework/fastapi/server.py +182 -0
- python_ddd_framework/fastapi/settings.py +40 -0
- python_ddd_framework/fastapi/tracing.py +62 -0
- python_ddd_framework/fastapi/transfer.py +128 -0
- python_ddd_framework/fastapi/upload_limits.py +63 -0
- python_ddd_framework/fastapi/uploads.py +94 -0
- python_ddd_framework/hosted_services/__init__.py +29 -0
- python_ddd_framework/hosted_services/bridge.py +420 -0
- python_ddd_framework/hosted_services/catalog.py +265 -0
- python_ddd_framework/hosted_services/contracts.py +55 -0
- python_ddd_framework/hosted_services/errors.py +53 -0
- python_ddd_framework/hosted_services/options.py +17 -0
- python_ddd_framework/hosted_services/runtime.py +267 -0
- python_ddd_framework/hosted_services/state.py +40 -0
- python_ddd_framework/hosting/__init__.py +4 -0
- python_ddd_framework/hosting/instance.py +40 -0
- python_ddd_framework/identity/__init__.py +73 -0
- python_ddd_framework/identity/application.py +287 -0
- python_ddd_framework/identity/contracts.py +283 -0
- python_ddd_framework/identity/errors.py +28 -0
- python_ddd_framework/identity/http_api.py +90 -0
- python_ddd_framework/identity/module.py +49 -0
- python_ddd_framework/identity/passwords.py +34 -0
- python_ddd_framework/identity/permissions.py +14 -0
- python_ddd_framework/identity/services.py +108 -0
- python_ddd_framework/identity/sqlalchemy/__init__.py +5 -0
- python_ddd_framework/identity/sqlalchemy/migrations/0001_identity.py +143 -0
- python_ddd_framework/identity/sqlalchemy/migrations/0002_physical_delete.py +42 -0
- python_ddd_framework/identity/sqlalchemy/migrations/__init__.py +1 -0
- python_ddd_framework/identity/sqlalchemy/models.py +85 -0
- python_ddd_framework/identity/sqlalchemy/module.py +62 -0
- python_ddd_framework/identity/sqlalchemy/stores.py +526 -0
- python_ddd_framework/identity/tokens.py +106 -0
- python_ddd_framework/invocation/__init__.py +3 -0
- python_ddd_framework/invocation/callables.py +171 -0
- python_ddd_framework/invocation/contracts.py +33 -0
- python_ddd_framework/invocation/entries.py +54 -0
- python_ddd_framework/invocation/function_runtime.py +48 -0
- python_ddd_framework/invocation/interception.py +193 -0
- python_ddd_framework/lifecycle/__init__.py +25 -0
- python_ddd_framework/lifecycle/composition.py +35 -0
- python_ddd_framework/lifecycle/context.py +31 -0
- python_ddd_framework/lifecycle/runtime.py +43 -0
- python_ddd_framework/lifecycle/state.py +20 -0
- python_ddd_framework/modularity/__init__.py +15 -0
- python_ddd_framework/modularity/contracts.py +91 -0
- python_ddd_framework/modularity/discovery.py +157 -0
- python_ddd_framework/modularity/graph.py +334 -0
- python_ddd_framework/modularity/registry.py +100 -0
- python_ddd_framework/modularity/selection.py +16 -0
- python_ddd_framework/notifications/__init__.py +19 -0
- python_ddd_framework/notifications/catalog.py +46 -0
- python_ddd_framework/notifications/contracts.py +107 -0
- python_ddd_framework/observability/__init__.py +5 -0
- python_ddd_framework/observability/context.py +43 -0
- python_ddd_framework/observability/export.py +73 -0
- python_ddd_framework/observability/formatting.py +89 -0
- python_ddd_framework/observability/logging.py +118 -0
- python_ddd_framework/observability/options.py +45 -0
- python_ddd_framework/observability/tracing.py +80 -0
- python_ddd_framework/options/__init__.py +19 -0
- python_ddd_framework/options/aliases.py +331 -0
- python_ddd_framework/options/contribution.py +62 -0
- python_ddd_framework/options/immutability.py +48 -0
- python_ddd_framework/options/input_keys.py +214 -0
- python_ddd_framework/options/issues.py +335 -0
- python_ddd_framework/options/models.py +114 -0
- python_ddd_framework/options/registry.py +280 -0
- python_ddd_framework/options/schema.py +488 -0
- python_ddd_framework/options/validation.py +120 -0
- python_ddd_framework/py.typed +0 -0
- python_ddd_framework/realtime/__init__.py +12 -0
- python_ddd_framework/realtime/contracts.py +28 -0
- python_ddd_framework/realtime/diagnostics.py +37 -0
- python_ddd_framework/realtime/messages.py +113 -0
- python_ddd_framework/redis/__init__.py +6 -0
- python_ddd_framework/redis/distributed_lock.py +212 -0
- python_ddd_framework/redis/lease_lock.py +36 -0
- python_ddd_framework/redis/module.py +50 -0
- python_ddd_framework/redis/notification_runtime.py +182 -0
- python_ddd_framework/redis/notifications.py +25 -0
- python_ddd_framework/redis/options.py +35 -0
- python_ddd_framework/redis/runtime.py +116 -0
- python_ddd_framework/services/__init__.py +41 -0
- python_ddd_framework/services/application_bindings.py +221 -0
- python_ddd_framework/services/arbitration.py +381 -0
- python_ddd_framework/services/binding.py +102 -0
- python_ddd_framework/services/contribution.py +438 -0
- python_ddd_framework/services/convention.py +301 -0
- python_ddd_framework/services/convention_contracts.py +101 -0
- python_ddd_framework/services/exposure.py +23 -0
- python_ddd_framework/services/fixed_lifetime.py +52 -0
- python_ddd_framework/services/framework_provider.py +178 -0
- python_ddd_framework/services/native_graph.py +201 -0
- python_ddd_framework/services/provider.py +204 -0
- python_ddd_framework/services/registration.py +101 -0
- python_ddd_framework/services/repository.py +38 -0
- python_ddd_framework/services/runtime.py +302 -0
- python_ddd_framework/settings/__init__.py +30 -0
- python_ddd_framework/settings/application.py +95 -0
- python_ddd_framework/settings/binding.py +33 -0
- python_ddd_framework/settings/catalog.py +121 -0
- python_ddd_framework/settings/changes.py +14 -0
- python_ddd_framework/settings/contracts.py +115 -0
- python_ddd_framework/settings/definition_discovery.py +71 -0
- python_ddd_framework/settings/definitions.py +41 -0
- python_ddd_framework/settings/errors.py +22 -0
- python_ddd_framework/settings/handlers.py +43 -0
- python_ddd_framework/settings/management.py +57 -0
- python_ddd_framework/settings/manager.py +89 -0
- python_ddd_framework/settings/module.py +10 -0
- python_ddd_framework/settings/notifications.py +39 -0
- python_ddd_framework/settings/permissions.py +14 -0
- python_ddd_framework/settings/provider.py +74 -0
- python_ddd_framework/settings/refresh.py +140 -0
- python_ddd_framework/settings/refresh_module.py +45 -0
- python_ddd_framework/settings/sqlalchemy/__init__.py +5 -0
- python_ddd_framework/settings/sqlalchemy/migrations/0001_settings.py +31 -0
- python_ddd_framework/settings/sqlalchemy/migrations/0002_physical_delete.py +32 -0
- python_ddd_framework/settings/sqlalchemy/migrations/0003_version_tokens.py +39 -0
- python_ddd_framework/settings/sqlalchemy/migrations/__init__.py +1 -0
- python_ddd_framework/settings/sqlalchemy/models.py +36 -0
- python_ddd_framework/settings/sqlalchemy/module.py +36 -0
- python_ddd_framework/settings/sqlalchemy/store.py +86 -0
- python_ddd_framework/settings/store.py +16 -0
- python_ddd_framework/settings/values.py +39 -0
- python_ddd_framework/sqlalchemy/__init__.py +54 -0
- python_ddd_framework/sqlalchemy/alembic_runtime/__init__.py +1 -0
- python_ddd_framework/sqlalchemy/alembic_runtime/env.py +33 -0
- python_ddd_framework/sqlalchemy/alembic_runtime/script.py.mako +14 -0
- python_ddd_framework/sqlalchemy/auditing.py +48 -0
- python_ddd_framework/sqlalchemy/errors.py +85 -0
- python_ddd_framework/sqlalchemy/metadata.py +576 -0
- python_ddd_framework/sqlalchemy/migration.py +378 -0
- python_ddd_framework/sqlalchemy/migration_options.py +56 -0
- python_ddd_framework/sqlalchemy/module.py +51 -0
- python_ddd_framework/sqlalchemy/module_migration.py +151 -0
- python_ddd_framework/sqlalchemy/options.py +66 -0
- python_ddd_framework/sqlalchemy/repository.py +33 -0
- python_ddd_framework/sqlalchemy/runtime.py +85 -0
- python_ddd_framework/sqlalchemy/session_provider.py +45 -0
- python_ddd_framework/sqlalchemy/unit_of_work.py +97 -0
- python_ddd_framework/testing/__init__.py +5 -0
- python_ddd_framework/testing/runtime.py +112 -0
- python_ddd_framework/unit_of_work/__init__.py +14 -0
- python_ddd_framework/unit_of_work/contracts.py +223 -0
- python_ddd_framework/unit_of_work/errors.py +27 -0
- python_ddd_framework/unit_of_work/manager.py +210 -0
- python_ddd_framework/unit_of_work/options.py +92 -0
- python_ddd_framework-0.3.1.dist-info/METADATA +379 -0
- python_ddd_framework-0.3.1.dist-info/RECORD +355 -0
- python_ddd_framework-0.3.1.dist-info/WHEEL +4 -0
- python_ddd_framework-0.3.1.dist-info/entry_points.txt +9 -0
- python_ddd_framework-0.3.1.dist-info/licenses/LICENSE +7 -0
- python_ddd_framework-0.3.1.dist-info/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Host-owned Alembic orchestration;只消费 live Module metadata,不维护模块清单。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import partial
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from alembic import command
|
|
14
|
+
from alembic.config import Config
|
|
15
|
+
from alembic.migration import MigrationContext
|
|
16
|
+
from alembic.script import ScriptDirectory
|
|
17
|
+
from sqlalchemy import Connection
|
|
18
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
19
|
+
from sqlalchemy.pool import NullPool
|
|
20
|
+
|
|
21
|
+
from ..background_workers import BackgroundExecutionOptions, BackgroundExecutionProfile
|
|
22
|
+
from ..lifecycle import ApplicationState
|
|
23
|
+
from ..modularity import AppModule, ModuleKey
|
|
24
|
+
from ..options import OptionsSnapshot
|
|
25
|
+
from .errors import (
|
|
26
|
+
MigrationHeadsMismatchError,
|
|
27
|
+
SoftDeleteMigrationBlockedError,
|
|
28
|
+
SqlAlchemyMigrationError,
|
|
29
|
+
SqlAlchemyModelRegistrationError,
|
|
30
|
+
)
|
|
31
|
+
from .metadata import SqlAlchemyModelCatalog, _build_model_catalog
|
|
32
|
+
from .migration_options import SqlAlchemyMigrationOptions
|
|
33
|
+
from .module_migration import _ModuleMigration
|
|
34
|
+
from .options import ConnectionStringsOptions
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from ..application import Application
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SqlAlchemyMigrator:
|
|
41
|
+
__slots__ = ("_application",)
|
|
42
|
+
|
|
43
|
+
def __init__(self, application: Application) -> None:
|
|
44
|
+
self._application = application
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def model_catalog(self) -> SqlAlchemyModelCatalog:
|
|
48
|
+
"""供原生 Alembic authoring 消费 live ownership,不维护第二份迁移路径清单。"""
|
|
49
|
+
if self._application.state is not ApplicationState.COMPOSED:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"SqlAlchemyMigrator requires composed Application, "
|
|
52
|
+
f"got {self._application.state.value}"
|
|
53
|
+
)
|
|
54
|
+
models = _build_model_catalog(self._application.modules, self._application.options)
|
|
55
|
+
models._assert_unchanged()
|
|
56
|
+
return models
|
|
57
|
+
|
|
58
|
+
async def upgrade_heads(self, *, module: type[AppModule] | None = None) -> tuple[str, ...]:
|
|
59
|
+
return await self._run(
|
|
60
|
+
"upgrade heads",
|
|
61
|
+
_upgrade_heads,
|
|
62
|
+
module=module,
|
|
63
|
+
module_operation=_ModuleMigration.upgrade,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def check_current_heads(
|
|
67
|
+
self, *, module: type[AppModule] | None = None
|
|
68
|
+
) -> tuple[str, ...]:
|
|
69
|
+
return await self._run("check current heads", _check_current_heads, module=module)
|
|
70
|
+
|
|
71
|
+
async def revision(self, module: type[AppModule]) -> tuple[str, ...]:
|
|
72
|
+
return await self._run(
|
|
73
|
+
"revision",
|
|
74
|
+
_check_current_heads,
|
|
75
|
+
module=module,
|
|
76
|
+
module_operation=_ModuleMigration.revision,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def _run(
|
|
80
|
+
self,
|
|
81
|
+
operation: str,
|
|
82
|
+
callback: Callable[[Connection, Config, str], None],
|
|
83
|
+
*,
|
|
84
|
+
module: type[AppModule] | None = None,
|
|
85
|
+
module_operation: Callable[
|
|
86
|
+
[_ModuleMigration, Connection, Config, str], None
|
|
87
|
+
] = _ModuleMigration.status,
|
|
88
|
+
) -> tuple[str, ...]:
|
|
89
|
+
models = self.model_catalog
|
|
90
|
+
owner = ModuleKey(module.__module__, module.__qualname__) if module is not None else None
|
|
91
|
+
if module_operation is _ModuleMigration.revision and not any(
|
|
92
|
+
item.owner == owner for item in models.contributions
|
|
93
|
+
):
|
|
94
|
+
raise SqlAlchemyModelRegistrationError(
|
|
95
|
+
owner=str(owner), reason="revision requires a Module with registered ORM models"
|
|
96
|
+
)
|
|
97
|
+
options = self._application.options.get(ConnectionStringsOptions)
|
|
98
|
+
configured_names = set(options.connection_names)
|
|
99
|
+
missing = set(models.connection_names) - configured_names
|
|
100
|
+
if missing:
|
|
101
|
+
from .errors import UnknownSqlAlchemyConnectionError
|
|
102
|
+
|
|
103
|
+
raise UnknownSqlAlchemyConnectionError(sorted(missing)[0])
|
|
104
|
+
|
|
105
|
+
completed: list[str] = []
|
|
106
|
+
for connection_name in options.connection_names:
|
|
107
|
+
migration_sources = models._migration_sources_for_connection(connection_name)
|
|
108
|
+
if owner is not None and not any(item.owner == owner for item in migration_sources):
|
|
109
|
+
continue
|
|
110
|
+
if not migration_sources:
|
|
111
|
+
completed.append(connection_name)
|
|
112
|
+
continue
|
|
113
|
+
config = _alembic_config(models, connection_name, self._application.options)
|
|
114
|
+
execution_options = self._application.options.get(BackgroundExecutionOptions)
|
|
115
|
+
config.attributes["background_execution_disabled"] = (
|
|
116
|
+
execution_options.profile is BackgroundExecutionProfile.DISABLED
|
|
117
|
+
)
|
|
118
|
+
_validate_revision_graph(
|
|
119
|
+
models,
|
|
120
|
+
connection_name,
|
|
121
|
+
config,
|
|
122
|
+
owner=owner,
|
|
123
|
+
allow_empty=module_operation is _ModuleMigration.revision,
|
|
124
|
+
)
|
|
125
|
+
selected_callback = callback
|
|
126
|
+
if module is not None:
|
|
127
|
+
selected = _ModuleMigration(models, module, connection_name, config)
|
|
128
|
+
selected_callback = partial(module_operation, selected)
|
|
129
|
+
outcome = await _run_connection_migration(
|
|
130
|
+
options._url_for(connection_name),
|
|
131
|
+
config=config,
|
|
132
|
+
connection_name=connection_name,
|
|
133
|
+
callback=selected_callback,
|
|
134
|
+
)
|
|
135
|
+
if outcome.operation_completed:
|
|
136
|
+
completed.append(connection_name)
|
|
137
|
+
_raise_migration_error(
|
|
138
|
+
outcome,
|
|
139
|
+
operation=operation,
|
|
140
|
+
connection_name=connection_name,
|
|
141
|
+
completed_connections=tuple(completed),
|
|
142
|
+
)
|
|
143
|
+
return tuple(completed)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class _MigrationOutcome:
|
|
148
|
+
operation_completed: bool
|
|
149
|
+
error_type: str | None
|
|
150
|
+
cleanup_error_types: tuple[str, ...]
|
|
151
|
+
control_flow_error: BaseException | None
|
|
152
|
+
affected_tables: tuple[tuple[str, int], ...] = ()
|
|
153
|
+
registration_error: SqlAlchemyModelRegistrationError | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def _run_connection_migration(
|
|
157
|
+
url: str,
|
|
158
|
+
*,
|
|
159
|
+
config: Config,
|
|
160
|
+
connection_name: str,
|
|
161
|
+
callback: Callable[[Connection, Config, str], None],
|
|
162
|
+
) -> _MigrationOutcome:
|
|
163
|
+
engine: AsyncEngine | None = None
|
|
164
|
+
operation_completed = False
|
|
165
|
+
error_type: str | None = None
|
|
166
|
+
control_flow_error: BaseException | None = None
|
|
167
|
+
affected_tables: tuple[tuple[str, int], ...] = ()
|
|
168
|
+
registration_error: SqlAlchemyModelRegistrationError | None = None
|
|
169
|
+
try:
|
|
170
|
+
engine = create_async_engine(url, poolclass=NullPool)
|
|
171
|
+
async with engine.connect() as connection:
|
|
172
|
+
config.attributes["connection"] = connection
|
|
173
|
+
await connection.run_sync(
|
|
174
|
+
partial(
|
|
175
|
+
_invoke_callback,
|
|
176
|
+
callback=callback,
|
|
177
|
+
config=config,
|
|
178
|
+
connection_name=connection_name,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
operation_completed = True
|
|
182
|
+
except SqlAlchemyModelRegistrationError as invalid:
|
|
183
|
+
registration_error = invalid
|
|
184
|
+
except SoftDeleteMigrationBlockedError as blocked:
|
|
185
|
+
error_type = type(blocked).__name__
|
|
186
|
+
affected_tables = blocked.affected_tables
|
|
187
|
+
except Exception as original_error:
|
|
188
|
+
error_type = type(original_error).__name__
|
|
189
|
+
except BaseException as original_control_flow:
|
|
190
|
+
control_flow_error = original_control_flow
|
|
191
|
+
|
|
192
|
+
cleanup_error_types: tuple[str, ...] = ()
|
|
193
|
+
if engine is not None:
|
|
194
|
+
cleanup_error_types, cleanup_control_flow = await _dispose_engine(engine)
|
|
195
|
+
if control_flow_error is None:
|
|
196
|
+
control_flow_error = cleanup_control_flow
|
|
197
|
+
return _MigrationOutcome(
|
|
198
|
+
operation_completed=operation_completed,
|
|
199
|
+
error_type=error_type,
|
|
200
|
+
cleanup_error_types=cleanup_error_types,
|
|
201
|
+
control_flow_error=control_flow_error,
|
|
202
|
+
affected_tables=affected_tables,
|
|
203
|
+
registration_error=registration_error,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _dispose_engine(
|
|
208
|
+
engine: AsyncEngine,
|
|
209
|
+
) -> tuple[tuple[str, ...], BaseException | None]:
|
|
210
|
+
# dispose 自己持有独立 task;外层 cancellation 只能延迟传播,不能截断连接池释放。
|
|
211
|
+
disposal = asyncio.create_task(engine.dispose())
|
|
212
|
+
control_flow_error: BaseException | None = None
|
|
213
|
+
cleanup_error_types: tuple[str, ...] = ()
|
|
214
|
+
while not disposal.done():
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.shield(disposal)
|
|
217
|
+
except asyncio.CancelledError as original_cancellation:
|
|
218
|
+
if control_flow_error is None:
|
|
219
|
+
control_flow_error = original_cancellation
|
|
220
|
+
except Exception as cleanup_error:
|
|
221
|
+
cleanup_error_types = (type(cleanup_error).__name__,)
|
|
222
|
+
break
|
|
223
|
+
if disposal.done() and not cleanup_error_types:
|
|
224
|
+
try:
|
|
225
|
+
disposal.result()
|
|
226
|
+
except asyncio.CancelledError as disposal_cancellation:
|
|
227
|
+
if control_flow_error is None:
|
|
228
|
+
control_flow_error = disposal_cancellation
|
|
229
|
+
except Exception as cleanup_error:
|
|
230
|
+
cleanup_error_types = (type(cleanup_error).__name__,)
|
|
231
|
+
return cleanup_error_types, control_flow_error
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _raise_migration_error(
|
|
235
|
+
outcome: _MigrationOutcome,
|
|
236
|
+
*,
|
|
237
|
+
operation: str,
|
|
238
|
+
connection_name: str,
|
|
239
|
+
completed_connections: tuple[str, ...],
|
|
240
|
+
) -> None:
|
|
241
|
+
failure: BaseException | None = None
|
|
242
|
+
if outcome.error_type is not None or outcome.cleanup_error_types:
|
|
243
|
+
failure = SqlAlchemyMigrationError(
|
|
244
|
+
operation=operation,
|
|
245
|
+
failed_connection=connection_name,
|
|
246
|
+
completed_connections=completed_connections,
|
|
247
|
+
error_type=outcome.error_type or outcome.cleanup_error_types[0],
|
|
248
|
+
cleanup_error_types=outcome.cleanup_error_types,
|
|
249
|
+
affected_tables=outcome.affected_tables,
|
|
250
|
+
)
|
|
251
|
+
if outcome.registration_error is not None:
|
|
252
|
+
outcome.registration_error.__cause__ = failure
|
|
253
|
+
failure = outcome.registration_error
|
|
254
|
+
# 控制流先传播,已确认的前置条件错误与清理故障保留在 cause 链中。
|
|
255
|
+
if outcome.control_flow_error is not None:
|
|
256
|
+
if failure is not None:
|
|
257
|
+
raise outcome.control_flow_error from failure
|
|
258
|
+
raise outcome.control_flow_error
|
|
259
|
+
if failure is not None:
|
|
260
|
+
raise failure
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _invoke_callback(
|
|
264
|
+
connection: Connection,
|
|
265
|
+
*,
|
|
266
|
+
callback: Callable[[Connection, Config, str], None],
|
|
267
|
+
config: Config,
|
|
268
|
+
connection_name: str,
|
|
269
|
+
) -> None:
|
|
270
|
+
callback(connection, config, connection_name)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _alembic_config(
|
|
274
|
+
models: SqlAlchemyModelCatalog, connection_name: str, options: OptionsSnapshot
|
|
275
|
+
) -> Config:
|
|
276
|
+
migration_sources = models._migration_sources_for_connection(connection_name)
|
|
277
|
+
model_contributions = models.for_connection(connection_name)
|
|
278
|
+
migration_options = options.get(SqlAlchemyMigrationOptions)
|
|
279
|
+
unknown = set(migration_options.named) - set(
|
|
280
|
+
options.get(ConnectionStringsOptions).connection_names
|
|
281
|
+
)
|
|
282
|
+
if unknown:
|
|
283
|
+
from .errors import UnknownSqlAlchemyConnectionError
|
|
284
|
+
|
|
285
|
+
raise UnknownSqlAlchemyConnectionError(sorted(unknown)[0])
|
|
286
|
+
version_table = migration_options._for_connection(connection_name)
|
|
287
|
+
for contribution in models.migrations_for_connection(connection_name):
|
|
288
|
+
if version_table.version_table_schema in contribution.registration.owned_schemas:
|
|
289
|
+
# migration-only owner 可整体删除自己的 Schema,不能同时拥有迁移记录。
|
|
290
|
+
raise SqlAlchemyModelRegistrationError(
|
|
291
|
+
owner=str(contribution.owner),
|
|
292
|
+
reason="version table schema must be separate from migration-owned schemas",
|
|
293
|
+
)
|
|
294
|
+
config = Config()
|
|
295
|
+
config.set_main_option(
|
|
296
|
+
"script_location",
|
|
297
|
+
str(Path(__file__).with_name("alembic_runtime")),
|
|
298
|
+
)
|
|
299
|
+
config.set_main_option(
|
|
300
|
+
"version_locations",
|
|
301
|
+
os.pathsep.join(str(item.migration_path) for item in migration_sources),
|
|
302
|
+
)
|
|
303
|
+
config.set_main_option("path_separator", "os")
|
|
304
|
+
config.attributes["target_metadata"] = tuple(
|
|
305
|
+
item.registration.metadata for item in model_contributions
|
|
306
|
+
)
|
|
307
|
+
# 原生 Alembic context 消费构建 catalog 的同一快照,不再读取进程环境。
|
|
308
|
+
config.attributes["options_snapshot"] = options
|
|
309
|
+
config.attributes["version_table_options"] = {
|
|
310
|
+
"version_table": version_table.version_table,
|
|
311
|
+
"version_table_schema": version_table.version_table_schema,
|
|
312
|
+
}
|
|
313
|
+
return config
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _validate_revision_graph(
|
|
317
|
+
models: SqlAlchemyModelCatalog,
|
|
318
|
+
connection_name: str,
|
|
319
|
+
config: Config,
|
|
320
|
+
*,
|
|
321
|
+
owner: ModuleKey | None = None,
|
|
322
|
+
allow_empty: bool = False,
|
|
323
|
+
) -> None:
|
|
324
|
+
migration_sources = models._migration_sources_for_connection(connection_name)
|
|
325
|
+
# walk_revisions 强制 Alembic 在任何 engine 创建前加载并校验组合 graph/depends_on。
|
|
326
|
+
revisions = tuple(ScriptDirectory.from_config(config).walk_revisions())
|
|
327
|
+
for source in migration_sources:
|
|
328
|
+
owned_revisions = tuple(
|
|
329
|
+
revision
|
|
330
|
+
for revision in revisions
|
|
331
|
+
if Path(revision.path).resolve().parent == source.migration_path
|
|
332
|
+
)
|
|
333
|
+
if not owned_revisions:
|
|
334
|
+
if (owner is not None and source.owner != owner) or allow_empty:
|
|
335
|
+
continue
|
|
336
|
+
raise SqlAlchemyModelRegistrationError(
|
|
337
|
+
owner=str(source.owner),
|
|
338
|
+
reason=(
|
|
339
|
+
f"migration package {source.migrations_package!r} "
|
|
340
|
+
"does not contain an Alembic revision; "
|
|
341
|
+
"run pddd db revision --module <alias> first"
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
expected_labels = {source.branch_label}
|
|
345
|
+
for revision in owned_revisions:
|
|
346
|
+
if revision.branch_labels != expected_labels:
|
|
347
|
+
actual_labels = tuple(sorted(revision.branch_labels))
|
|
348
|
+
raise SqlAlchemyModelRegistrationError(
|
|
349
|
+
owner=str(source.owner),
|
|
350
|
+
reason=(
|
|
351
|
+
f"revision {revision.revision!r} exposes branch_labels "
|
|
352
|
+
f"{actual_labels!r}, expected {(source.branch_label,)!r}"
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _upgrade_heads(connection: Connection, config: Config, connection_name: str) -> None:
|
|
358
|
+
config.attributes["connection"] = connection
|
|
359
|
+
command.upgrade(config, "heads")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _check_current_heads(connection: Connection, config: Config, connection_name: str) -> None:
|
|
363
|
+
config.attributes["connection"] = connection
|
|
364
|
+
script = ScriptDirectory.from_config(config)
|
|
365
|
+
current = tuple(
|
|
366
|
+
sorted(
|
|
367
|
+
MigrationContext.configure(
|
|
368
|
+
connection, opts=config.attributes["version_table_options"]
|
|
369
|
+
).get_current_heads()
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
expected = tuple(sorted(script.get_heads()))
|
|
373
|
+
if current != expected:
|
|
374
|
+
raise MigrationHeadsMismatchError(
|
|
375
|
+
connection_name=connection_name,
|
|
376
|
+
current_heads=current,
|
|
377
|
+
expected_heads=expected,
|
|
378
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Host-owned Alembic version-table locations;revision 身份仍由脚本拥有。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from pydantic import ConfigDict, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from ..options import BaseOptions
|
|
11
|
+
from .options import _validate_connection_name
|
|
12
|
+
|
|
13
|
+
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,62}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SqlAlchemyVersionTable(BaseOptions):
|
|
17
|
+
model_config = ConfigDict(extra="forbid")
|
|
18
|
+
|
|
19
|
+
# Alembic 1.19.1 为主键追加 _pkc;完整约束名也必须不超过 PostgreSQL 的 63 字节。
|
|
20
|
+
version_table: str = Field(default="alembic_version", max_length=59)
|
|
21
|
+
version_table_schema: str = "public"
|
|
22
|
+
|
|
23
|
+
@field_validator("version_table", "version_table_schema")
|
|
24
|
+
@classmethod
|
|
25
|
+
def _identifier_is_valid(cls, value: str) -> str:
|
|
26
|
+
if not _IDENTIFIER.fullmatch(value):
|
|
27
|
+
raise ValueError("version-table identifiers must contain 1-63 ASCII identifier bytes")
|
|
28
|
+
return value.lower()
|
|
29
|
+
|
|
30
|
+
@field_validator("version_table_schema")
|
|
31
|
+
@classmethod
|
|
32
|
+
def _schema_is_writable(cls, value: str) -> str:
|
|
33
|
+
if value == "information_schema" or value.startswith("pg_"):
|
|
34
|
+
raise ValueError("version table must not use a PostgreSQL system schema")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SqlAlchemyMigrationOptions(BaseOptions):
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
40
|
+
|
|
41
|
+
default: SqlAlchemyVersionTable = Field(default_factory=SqlAlchemyVersionTable)
|
|
42
|
+
named: Mapping[str, SqlAlchemyVersionTable] = Field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@field_validator("named")
|
|
45
|
+
@classmethod
|
|
46
|
+
def _named_connections_are_valid(
|
|
47
|
+
cls, value: Mapping[str, SqlAlchemyVersionTable]
|
|
48
|
+
) -> Mapping[str, SqlAlchemyVersionTable]:
|
|
49
|
+
for name in value:
|
|
50
|
+
_validate_connection_name(name)
|
|
51
|
+
if name == "default":
|
|
52
|
+
raise ValueError("named migration options must not redefine 'default'")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
def _for_connection(self, connection_name: str) -> SqlAlchemyVersionTable:
|
|
56
|
+
return self.named.get(connection_name, self.default)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Host 显式依赖的 provider-neutral SQLAlchemy persistence Module。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dishka import Provider, Scope
|
|
6
|
+
|
|
7
|
+
from ..lifecycle import ConfigureContext, InitializeContext
|
|
8
|
+
from ..modularity import AppModule, ModuleRegistry
|
|
9
|
+
from ..options import OptionsSnapshot
|
|
10
|
+
from ..unit_of_work.contracts import _UnitOfWorkFactory
|
|
11
|
+
from .metadata import SqlAlchemyModelCatalog, _build_model_catalog
|
|
12
|
+
from .migration_options import SqlAlchemyMigrationOptions
|
|
13
|
+
from .options import ConnectionStringsOptions
|
|
14
|
+
from .runtime import (
|
|
15
|
+
_engine_registry,
|
|
16
|
+
_SqlAlchemyEngineRegistry,
|
|
17
|
+
)
|
|
18
|
+
from .session_provider import SqlAlchemySessionProvider
|
|
19
|
+
from .unit_of_work import _SqlAlchemyUnitOfWorkFactory
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SqlAlchemyPersistenceModule(AppModule):
|
|
23
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
24
|
+
context.configure(
|
|
25
|
+
ConnectionStringsOptions,
|
|
26
|
+
section="connection_strings",
|
|
27
|
+
secret_paths=frozenset({"default", "named"}),
|
|
28
|
+
)
|
|
29
|
+
context.configure(SqlAlchemyMigrationOptions, section="sqlalchemy_migrations")
|
|
30
|
+
provider = Provider()
|
|
31
|
+
provider.provide(
|
|
32
|
+
_model_catalog,
|
|
33
|
+
provides=SqlAlchemyModelCatalog,
|
|
34
|
+
scope=Scope.APP,
|
|
35
|
+
)
|
|
36
|
+
provider.provide(
|
|
37
|
+
_engine_registry,
|
|
38
|
+
provides=_SqlAlchemyEngineRegistry,
|
|
39
|
+
scope=Scope.APP,
|
|
40
|
+
)
|
|
41
|
+
provider.provide(_SqlAlchemyUnitOfWorkFactory, provides=_UnitOfWorkFactory, scope=Scope.APP)
|
|
42
|
+
provider.provide(SqlAlchemySessionProvider, scope=Scope.APP)
|
|
43
|
+
context.services.contribute(provider, reason="SQLAlchemy PostgreSQL persistence boundary")
|
|
44
|
+
|
|
45
|
+
async def initialize(self, context: InitializeContext) -> None:
|
|
46
|
+
engines = await context.container.get(_SqlAlchemyEngineRegistry)
|
|
47
|
+
await engines.ping_all()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _model_catalog(registry: ModuleRegistry, options: OptionsSnapshot) -> SqlAlchemyModelCatalog:
|
|
51
|
+
return _build_model_catalog(registry, options)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""单模块 Alembic 操作:原生图决定前置关系,模型 catalog 决定比较边界。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from sysconfig import get_path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from alembic import command
|
|
10
|
+
from alembic.autogenerate import produce_migrations
|
|
11
|
+
from alembic.config import Config
|
|
12
|
+
from alembic.migration import MigrationContext
|
|
13
|
+
from alembic.operations.ops import MigrationScript
|
|
14
|
+
from alembic.script import ScriptDirectory
|
|
15
|
+
from sqlalchemy import Connection
|
|
16
|
+
|
|
17
|
+
from ..modularity import AppModule, ModuleKey
|
|
18
|
+
from .errors import SqlAlchemyModelRegistrationError
|
|
19
|
+
from .metadata import SqlAlchemyModelCatalog
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _ModuleMigration:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
models: SqlAlchemyModelCatalog,
|
|
26
|
+
module: type[AppModule],
|
|
27
|
+
connection_name: str,
|
|
28
|
+
config: Config,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.owner = ModuleKey(module.__module__, module.__qualname__)
|
|
31
|
+
sources = models._migration_sources_for_connection(connection_name)
|
|
32
|
+
self.sources = tuple(item for item in sources if item.owner == self.owner)
|
|
33
|
+
self.metadata = tuple(
|
|
34
|
+
item.registration.metadata
|
|
35
|
+
for item in models.for_connection(connection_name)
|
|
36
|
+
if item.owner == self.owner
|
|
37
|
+
)
|
|
38
|
+
self.script = ScriptDirectory.from_config(config)
|
|
39
|
+
self._version_table_options = config.attributes["version_table_options"]
|
|
40
|
+
self.revisions = tuple(self.script.walk_revisions())
|
|
41
|
+
|
|
42
|
+
def _owned(self) -> frozenset[str]:
|
|
43
|
+
paths = {item.migration_path for item in self.sources}
|
|
44
|
+
return frozenset(
|
|
45
|
+
item.revision for item in self.revisions if Path(item.path).resolve().parent in paths
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _heads(self) -> tuple[str, ...]:
|
|
49
|
+
return tuple(
|
|
50
|
+
revision.revision
|
|
51
|
+
for source in self.sources
|
|
52
|
+
for revision in self.script.get_revisions(f"{source.branch_label}@heads")
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _applied(self, connection: Connection) -> frozenset[str]:
|
|
56
|
+
current = MigrationContext.configure(
|
|
57
|
+
connection, opts=self._version_table_options
|
|
58
|
+
).get_current_heads()
|
|
59
|
+
# 由 Alembic 展开已应用的 ancestry/depends_on;不维护第二套 revision graph。
|
|
60
|
+
return frozenset(
|
|
61
|
+
item.revision
|
|
62
|
+
for item in self.script.iterate_revisions(current, "base", implicit_base=True)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _require(self, required: frozenset[str], applied: frozenset[str]) -> None:
|
|
66
|
+
missing = sorted(required - applied)
|
|
67
|
+
if missing:
|
|
68
|
+
raise SqlAlchemyModelRegistrationError(
|
|
69
|
+
owner=str(self.owner),
|
|
70
|
+
reason=(
|
|
71
|
+
f"Required migrations {missing} are not applied; "
|
|
72
|
+
"run pddd db upgrade --module <owning-alias> first"
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def upgrade(self, connection: Connection, config: Config, connection_name: str) -> None:
|
|
77
|
+
heads = self._heads()
|
|
78
|
+
ancestry = frozenset(
|
|
79
|
+
item.revision
|
|
80
|
+
for item in self.script.iterate_revisions(heads, "base", implicit_base=True)
|
|
81
|
+
)
|
|
82
|
+
# 预检 SELECT 会触发 SQLAlchemy autobegin;同一个显式事务拥有预检和全部目标分支,
|
|
83
|
+
# 避免 Alembic 将它视为外部事务后,连接退出时回滚已经执行的升级。
|
|
84
|
+
with connection.begin():
|
|
85
|
+
self._require(ancestry - self._owned(), self._applied(connection))
|
|
86
|
+
config.attributes["connection"] = connection
|
|
87
|
+
for head in heads:
|
|
88
|
+
command.upgrade(config, head)
|
|
89
|
+
|
|
90
|
+
def status(self, connection: Connection, config: Config, connection_name: str) -> None:
|
|
91
|
+
self._require(frozenset(self._heads()), self._applied(connection))
|
|
92
|
+
|
|
93
|
+
def revision(self, connection: Connection, config: Config, connection_name: str) -> None:
|
|
94
|
+
if len(self.sources) != 1 or not self.metadata:
|
|
95
|
+
raise SqlAlchemyModelRegistrationError(
|
|
96
|
+
owner=str(self.owner),
|
|
97
|
+
reason="revision requires one model-owned migration branch",
|
|
98
|
+
)
|
|
99
|
+
source = self.sources[0]
|
|
100
|
+
if not source.migration_path.is_relative_to(Path.cwd().resolve()) or any(
|
|
101
|
+
source.migration_path.is_relative_to(Path(get_path(kind)).resolve())
|
|
102
|
+
for kind in ("purelib", "platlib")
|
|
103
|
+
):
|
|
104
|
+
raise SqlAlchemyModelRegistrationError(
|
|
105
|
+
owner=str(self.owner),
|
|
106
|
+
reason="Revision authoring requires project-owned source, not an installed package",
|
|
107
|
+
)
|
|
108
|
+
owned = self._owned()
|
|
109
|
+
heads = self._heads() if owned else ()
|
|
110
|
+
required = frozenset(
|
|
111
|
+
item.revision
|
|
112
|
+
for item in self.script.iterate_revisions(heads, "base", implicit_base=True)
|
|
113
|
+
)
|
|
114
|
+
# Schema 依赖只来自原生 revision;Module 的运行时依赖不推断为数据库前置关系。
|
|
115
|
+
self._require(required, self._applied(connection))
|
|
116
|
+
config.attributes.update(
|
|
117
|
+
connection=connection,
|
|
118
|
+
target_metadata=self.metadata,
|
|
119
|
+
include_name=self._include_name,
|
|
120
|
+
)
|
|
121
|
+
config.set_main_option("revision_environment", "true")
|
|
122
|
+
# command.revision 的整库 autogenerate 检查会误阻塞无关未升级分支;改用其公开 hook
|
|
123
|
+
# 和 produce_migrations,仍由原生 renderer/ScriptDirectory 写 revision 与依赖声明。
|
|
124
|
+
command.revision(
|
|
125
|
+
config,
|
|
126
|
+
message=f"Update {source.branch_label}",
|
|
127
|
+
head=f"{source.branch_label}@head" if heads else "base",
|
|
128
|
+
branch_label=None if heads else source.branch_label,
|
|
129
|
+
version_path=str(source.migration_path),
|
|
130
|
+
process_revision_directives=self._generate,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _include_name(self, name: str | None, type_: str, parents: dict[str, Any]) -> bool:
|
|
134
|
+
if type_ == "schema":
|
|
135
|
+
return name in {
|
|
136
|
+
table.schema for meta in self.metadata for table in meta.tables.values()
|
|
137
|
+
}
|
|
138
|
+
if type_ == "table":
|
|
139
|
+
return any(
|
|
140
|
+
parents["schema_qualified_table_name"] in meta.tables for meta in self.metadata
|
|
141
|
+
)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
def _generate(
|
|
145
|
+
self, context: MigrationContext, revision: Any, directives: list[MigrationScript]
|
|
146
|
+
) -> None:
|
|
147
|
+
# 一个 migration source 对应一份已登记 MetaData;不复制表形成影子 metadata。
|
|
148
|
+
generated = produce_migrations(context, self.metadata[0])
|
|
149
|
+
assert generated.upgrade_ops is not None and generated.downgrade_ops is not None
|
|
150
|
+
directives[0].upgrade_ops = generated.upgrade_ops
|
|
151
|
+
directives[0].downgrade_ops = generated.downgrade_ops
|