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