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,140 @@
|
|
|
1
|
+
"""单进程 Settings 刷新 owner:合并读取、串行应用、失败后按周期重试。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
from pydantic import ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
from ..application_services.invocation import _InvocationRuntime
|
|
14
|
+
from ..hosted_services import HostedService, HostedServiceContext
|
|
15
|
+
from ..lifecycle import ShutdownReason
|
|
16
|
+
from ..observability.context import capture_trace_context, invocation_span
|
|
17
|
+
from ..options import BaseOptions, Options
|
|
18
|
+
from .contracts import SettingDefinition
|
|
19
|
+
from .handlers import SettingApplicationStatus, SettingHandler
|
|
20
|
+
from .provider import SettingProvider
|
|
21
|
+
from .values import SettingSnapshot, SettingValueSource
|
|
22
|
+
|
|
23
|
+
_LOGGER = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SettingsRefreshOptions(BaseOptions):
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
|
|
29
|
+
reconciliation_interval_seconds: float = Field(default=30.0, gt=0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class _ApplicationRecord:
|
|
34
|
+
handler: SettingHandler[Any]
|
|
35
|
+
last_applied: SettingSnapshot[Any] | None = None
|
|
36
|
+
error_type: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SettingsRefreshRuntime(HostedService):
|
|
40
|
+
run_in_background_processes: ClassVar[bool] = True
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
settings: SettingProvider,
|
|
45
|
+
options: Options[SettingsRefreshOptions],
|
|
46
|
+
runtime: _InvocationRuntime,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._settings = settings
|
|
49
|
+
self._options = options.value
|
|
50
|
+
self._runtime = runtime
|
|
51
|
+
self._definitions: dict[str, SettingDefinition[Any]] = {}
|
|
52
|
+
self._records: dict[str, list[_ApplicationRecord]] = {}
|
|
53
|
+
self._dirty: dict[str, dict[str, str]] = {}
|
|
54
|
+
self._wakeup = asyncio.Event()
|
|
55
|
+
self._task: asyncio.Task[None] | None = None
|
|
56
|
+
|
|
57
|
+
def _register(self, definition: SettingDefinition[Any], handler: SettingHandler[Any]) -> None:
|
|
58
|
+
self._definitions[definition.name] = definition
|
|
59
|
+
self._records.setdefault(definition.name, []).append(_ApplicationRecord(handler))
|
|
60
|
+
|
|
61
|
+
def request_refresh(self, name: str | None = None) -> None:
|
|
62
|
+
if name is None:
|
|
63
|
+
for key in self._definitions:
|
|
64
|
+
self._dirty.setdefault(key, {})
|
|
65
|
+
elif name in self._definitions:
|
|
66
|
+
self._dirty[name] = capture_trace_context(self._runtime.tracer)
|
|
67
|
+
if self._dirty:
|
|
68
|
+
self._wakeup.set()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def statuses(self) -> tuple[SettingApplicationStatus, ...]:
|
|
72
|
+
return tuple(
|
|
73
|
+
SettingApplicationStatus(
|
|
74
|
+
name,
|
|
75
|
+
f"{type(record.handler).__module__}.{type(record.handler).__qualname__}",
|
|
76
|
+
record.last_applied,
|
|
77
|
+
record.error_type,
|
|
78
|
+
)
|
|
79
|
+
for name, records in self._records.items()
|
|
80
|
+
for record in records
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def start(self, context: HostedServiceContext) -> None:
|
|
84
|
+
self.request_refresh()
|
|
85
|
+
self._task = asyncio.create_task(self._run(), name=context.name)
|
|
86
|
+
|
|
87
|
+
async def stop(self, reason: ShutdownReason) -> None:
|
|
88
|
+
if self._task is not None:
|
|
89
|
+
self._task.cancel()
|
|
90
|
+
with suppress(asyncio.CancelledError):
|
|
91
|
+
await self._task
|
|
92
|
+
|
|
93
|
+
async def _run(self) -> None:
|
|
94
|
+
if not self._definitions:
|
|
95
|
+
await asyncio.Future[None]()
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
await asyncio.wait_for(
|
|
99
|
+
self._wakeup.wait(), timeout=self._options.reconciliation_interval_seconds
|
|
100
|
+
)
|
|
101
|
+
except TimeoutError:
|
|
102
|
+
self.request_refresh()
|
|
103
|
+
# 取走本轮名字后立即清理;await 期间到达的新请求进入下一轮,不能被误清除。
|
|
104
|
+
requests = tuple(self._dirty.items())
|
|
105
|
+
self._dirty.clear()
|
|
106
|
+
self._wakeup.clear()
|
|
107
|
+
for name, carrier in requests:
|
|
108
|
+
with invocation_span(
|
|
109
|
+
self._runtime.tracer, f"refresh setting {name}", carrier=carrier
|
|
110
|
+
):
|
|
111
|
+
await self._refresh(name)
|
|
112
|
+
|
|
113
|
+
async def _refresh(self, name: str) -> None:
|
|
114
|
+
try:
|
|
115
|
+
latest = await self._settings.snapshot(self._definitions[name])
|
|
116
|
+
except Exception as error:
|
|
117
|
+
for record in self._records[name]:
|
|
118
|
+
record.error_type = type(error).__name__
|
|
119
|
+
_LOGGER.error("Settings refresh failed for %s: %s", name, type(error).__name__)
|
|
120
|
+
return
|
|
121
|
+
for record in self._records[name]:
|
|
122
|
+
previous = record.last_applied
|
|
123
|
+
if (
|
|
124
|
+
previous is not None
|
|
125
|
+
and record.error_type is None
|
|
126
|
+
and previous.value == latest.value
|
|
127
|
+
and (previous.source is SettingValueSource.UNSET)
|
|
128
|
+
== (latest.source is SettingValueSource.UNSET)
|
|
129
|
+
):
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
with invocation_span(self._runtime.tracer, f"apply setting {name}"):
|
|
133
|
+
await record.handler.apply(latest)
|
|
134
|
+
except Exception as error:
|
|
135
|
+
# 失败不能覆盖成功记录,下一次校对仍会按最后成功应用的值决定重试。
|
|
136
|
+
record.error_type = type(error).__name__
|
|
137
|
+
_LOGGER.error("Settings handler failed for %s: %s", name, record.error_type)
|
|
138
|
+
else:
|
|
139
|
+
record.last_applied = latest
|
|
140
|
+
record.error_type = None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""只组装正式 handler 与刷新运行时;组件应用规则由 handler 自己拥有。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dishka import DEFAULT_COMPONENT, DependencyKey
|
|
6
|
+
|
|
7
|
+
from python_platform import ConfigureContext
|
|
8
|
+
|
|
9
|
+
from ..lifecycle import PreInitializeContext, ShutdownContext
|
|
10
|
+
from ..modularity import AppModule
|
|
11
|
+
from .catalog import SettingCatalog
|
|
12
|
+
from .manager import SettingManager
|
|
13
|
+
from .refresh import SettingsRefreshOptions, SettingsRefreshRuntime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SettingsRefreshModule(AppModule):
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._manager: SettingManager | None = None
|
|
19
|
+
|
|
20
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
21
|
+
context.configure(SettingsRefreshOptions, section="settings_refresh")
|
|
22
|
+
|
|
23
|
+
hosted_services = (SettingsRefreshRuntime,)
|
|
24
|
+
|
|
25
|
+
async def pre_initialize(self, context: PreInitializeContext) -> None:
|
|
26
|
+
catalog = await context.container.get(SettingCatalog)
|
|
27
|
+
refresh = await context.container.get(SettingsRefreshRuntime)
|
|
28
|
+
for definition in catalog.handlers:
|
|
29
|
+
handler = await context.container.get(definition.handler_type)
|
|
30
|
+
refresh._register(definition.setting, handler)
|
|
31
|
+
# Manager 固定归 APP;查询最终原生图,含 alias,不构造服务或维护第二清单。
|
|
32
|
+
# 无存储仍应用只读值;已注册服务由原生 get 解析,构造失败不能降级。
|
|
33
|
+
if (
|
|
34
|
+
context.container.registry.get_factory(DependencyKey(SettingManager, DEFAULT_COMPONENT))
|
|
35
|
+
is not None
|
|
36
|
+
):
|
|
37
|
+
settings = await context.container.get(SettingManager)
|
|
38
|
+
settings._refresh = refresh
|
|
39
|
+
self._manager = settings
|
|
40
|
+
|
|
41
|
+
async def shutdown(self, context: ShutdownContext) -> None:
|
|
42
|
+
# 只解除本实例完成的接线;启动部分失败后的关闭不得重新构造依赖。
|
|
43
|
+
if self._manager is not None:
|
|
44
|
+
self._manager._refresh = None
|
|
45
|
+
self._manager = None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Create the independent platform settings migration branch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from alembic import op
|
|
7
|
+
|
|
8
|
+
revision = "platform_settings_0001"
|
|
9
|
+
down_revision = None
|
|
10
|
+
branch_labels = ("platform_settings",)
|
|
11
|
+
depends_on = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def upgrade() -> None:
|
|
15
|
+
op.create_table(
|
|
16
|
+
"platform_runtime_settings",
|
|
17
|
+
sa.Column("setting_name", sa.String(length=200), nullable=False),
|
|
18
|
+
sa.Column("value_json", sa.Text(), nullable=False),
|
|
19
|
+
sa.Column("revision", sa.BigInteger(), nullable=False),
|
|
20
|
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
|
21
|
+
sa.Column("created_by", sa.Uuid(), nullable=True),
|
|
22
|
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
|
23
|
+
sa.Column("updated_by", sa.Uuid(), nullable=True),
|
|
24
|
+
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
25
|
+
sa.Column("deleted_by", sa.Uuid(), nullable=True),
|
|
26
|
+
sa.PrimaryKeyConstraint("setting_name"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def downgrade() -> None:
|
|
31
|
+
op.drop_table("platform_runtime_settings")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Settings 重置物理删除覆盖值;历史软删除数据必须先由操作者处理。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from alembic import op
|
|
7
|
+
|
|
8
|
+
from python_platform.sqlalchemy.errors import SoftDeleteMigrationBlockedError
|
|
9
|
+
|
|
10
|
+
revision = "platform_settings_0002"
|
|
11
|
+
down_revision = "platform_settings_0001"
|
|
12
|
+
branch_labels = None
|
|
13
|
+
depends_on = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def upgrade() -> None:
|
|
17
|
+
connection = op.get_bind()
|
|
18
|
+
connection.execute(sa.text("LOCK TABLE platform_runtime_settings IN ACCESS EXCLUSIVE MODE"))
|
|
19
|
+
count = int(
|
|
20
|
+
connection.scalar(
|
|
21
|
+
sa.text("SELECT count(*) FROM platform_runtime_settings WHERE deleted_at IS NOT NULL")
|
|
22
|
+
)
|
|
23
|
+
or 0
|
|
24
|
+
)
|
|
25
|
+
if count:
|
|
26
|
+
raise SoftDeleteMigrationBlockedError((("platform_runtime_settings", count),))
|
|
27
|
+
op.drop_column("platform_runtime_settings", "deleted_at")
|
|
28
|
+
op.drop_column("platform_runtime_settings", "deleted_by")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def downgrade() -> None:
|
|
32
|
+
raise RuntimeError("physical deletion cannot restore historical deleted records")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""重置后保留版本令牌,覆盖值仍物理删除。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
from alembic import op
|
|
9
|
+
|
|
10
|
+
revision = "platform_settings_0003"
|
|
11
|
+
down_revision = "platform_settings_0002"
|
|
12
|
+
branch_labels = None
|
|
13
|
+
depends_on = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def upgrade() -> None:
|
|
17
|
+
connection = op.get_bind()
|
|
18
|
+
connection.execute(sa.text("LOCK TABLE platform_runtime_settings IN ACCESS EXCLUSIVE MODE"))
|
|
19
|
+
versions = op.create_table(
|
|
20
|
+
"platform_runtime_setting_versions",
|
|
21
|
+
sa.Column("setting_name", sa.String(200), primary_key=True),
|
|
22
|
+
sa.Column("version", sa.Uuid(), nullable=False),
|
|
23
|
+
)
|
|
24
|
+
# 不导入当前 ORM model:历史 revision 的结构独立冻结,不随未来模型变化。
|
|
25
|
+
names = connection.execute(sa.text("SELECT setting_name FROM platform_runtime_settings"))
|
|
26
|
+
for (name,) in names:
|
|
27
|
+
connection.execute(versions.insert().values(setting_name=name, version=uuid4()))
|
|
28
|
+
op.create_foreign_key(
|
|
29
|
+
"fk_runtime_setting_version",
|
|
30
|
+
"platform_runtime_settings",
|
|
31
|
+
"platform_runtime_setting_versions",
|
|
32
|
+
["setting_name"],
|
|
33
|
+
["setting_name"],
|
|
34
|
+
)
|
|
35
|
+
op.drop_column("platform_runtime_settings", "revision")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def downgrade() -> None:
|
|
39
|
+
raise RuntimeError("removing version tokens would allow stale settings writes")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Platform settings Alembic branch。"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Global Runtime Settings 的独立 metadata owner。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import ForeignKey, String, Text, Uuid
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
9
|
+
|
|
10
|
+
from ...sqlalchemy import AuditedEntityMixin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuntimeSettingsBase(DeclarativeBase):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RuntimeSettingRow(AuditedEntityMixin, RuntimeSettingsBase):
|
|
18
|
+
__tablename__ = "platform_runtime_settings"
|
|
19
|
+
|
|
20
|
+
setting_name: Mapped[str] = mapped_column(
|
|
21
|
+
String(200), ForeignKey("platform_runtime_setting_versions.setting_name"), primary_key=True
|
|
22
|
+
)
|
|
23
|
+
value_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RuntimeSettingVersionRow(RuntimeSettingsBase):
|
|
27
|
+
__tablename__ = "platform_runtime_setting_versions"
|
|
28
|
+
|
|
29
|
+
setting_name: Mapped[str] = mapped_column(String(200), primary_key=True)
|
|
30
|
+
version: Mapped[UUID] = mapped_column(Uuid, nullable=False)
|
|
31
|
+
|
|
32
|
+
# 重置只删除覆盖值;版本行保留,原生 ORM 比较令牌以拒绝过期写入。
|
|
33
|
+
__mapper_args__ = { # noqa: RUF012 -- SQLAlchemy 原生声明属性,基类不允许 ClassVar。
|
|
34
|
+
"version_id_col": version,
|
|
35
|
+
"version_id_generator": False,
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Host 显式选择的 Global Runtime Settings SQLAlchemy provider Module。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dishka import Provider, Scope
|
|
6
|
+
|
|
7
|
+
from ...lifecycle import ConfigureContext
|
|
8
|
+
from ...modularity import AppModule
|
|
9
|
+
from ...sqlalchemy import (
|
|
10
|
+
SqlAlchemyModelRegistration,
|
|
11
|
+
SqlAlchemyPersistenceModule,
|
|
12
|
+
contributes_sqlalchemy_models,
|
|
13
|
+
)
|
|
14
|
+
from ..store import RuntimeSettingStore
|
|
15
|
+
from .models import RuntimeSettingsBase
|
|
16
|
+
from .store import SqlAlchemyRuntimeSettingStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contributes_sqlalchemy_models(
|
|
20
|
+
SqlAlchemyModelRegistration(
|
|
21
|
+
metadata=RuntimeSettingsBase.metadata,
|
|
22
|
+
migrations_package="python_platform.settings.sqlalchemy.migrations",
|
|
23
|
+
branch_label="platform_settings",
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
class SqlAlchemyRuntimeSettingsModule(AppModule):
|
|
27
|
+
dependencies = (SqlAlchemyPersistenceModule,)
|
|
28
|
+
|
|
29
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
30
|
+
provider = Provider()
|
|
31
|
+
provider.provide(
|
|
32
|
+
SqlAlchemyRuntimeSettingStore,
|
|
33
|
+
provides=RuntimeSettingStore,
|
|
34
|
+
scope=Scope.APP,
|
|
35
|
+
)
|
|
36
|
+
context.services.contribute(provider, reason="SQLAlchemy Global Runtime Settings")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Global Runtime Settings 的独立短事务 store。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
9
|
+
from sqlalchemy.orm.exc import StaleDataError
|
|
10
|
+
|
|
11
|
+
from ...application_services.invocation import _InvocationRuntime
|
|
12
|
+
from ...errors.base import ResourceConflictError
|
|
13
|
+
from ...sqlalchemy import PlatformAsyncSession
|
|
14
|
+
from ...sqlalchemy.auditing import _bind_current_user
|
|
15
|
+
from ...sqlalchemy.runtime import _SqlAlchemyEngineRegistry
|
|
16
|
+
from ..errors import RuntimeSettingError
|
|
17
|
+
from ..values import StoredSetting
|
|
18
|
+
from .models import RuntimeSettingRow, RuntimeSettingVersionRow
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SqlAlchemyRuntimeSettingStore:
|
|
22
|
+
__slots__ = ("_engines", "_runtime")
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
engines: _SqlAlchemyEngineRegistry,
|
|
27
|
+
runtime: _InvocationRuntime,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._engines = engines
|
|
30
|
+
self._runtime = runtime
|
|
31
|
+
|
|
32
|
+
async def read(self, name: str) -> StoredSetting:
|
|
33
|
+
try:
|
|
34
|
+
async with self._session() as session:
|
|
35
|
+
row = (
|
|
36
|
+
await session.execute(
|
|
37
|
+
select(RuntimeSettingVersionRow.version, RuntimeSettingRow.value_json)
|
|
38
|
+
.outerjoin(RuntimeSettingRow)
|
|
39
|
+
.where(RuntimeSettingVersionRow.setting_name == name)
|
|
40
|
+
)
|
|
41
|
+
).one_or_none()
|
|
42
|
+
return StoredSetting(None, None) if row is None else StoredSetting(row[1], row[0])
|
|
43
|
+
except SQLAlchemyError as original_error:
|
|
44
|
+
raise _setting_error(name, "get", original_error) from None
|
|
45
|
+
|
|
46
|
+
async def write(
|
|
47
|
+
self, name: str, value_json: str | None, *, expected_version: UUID | None
|
|
48
|
+
) -> StoredSetting:
|
|
49
|
+
version = uuid4()
|
|
50
|
+
try:
|
|
51
|
+
async with self._session() as session, session.begin():
|
|
52
|
+
state = await session.get(RuntimeSettingVersionRow, name)
|
|
53
|
+
if (None if state is None else state.version) != expected_version:
|
|
54
|
+
raise ResourceConflictError("Setting version conflict")
|
|
55
|
+
if state is None:
|
|
56
|
+
session.add(RuntimeSettingVersionRow(setting_name=name, version=version))
|
|
57
|
+
else:
|
|
58
|
+
state.version = version
|
|
59
|
+
# 先由原生 version_id_col 抢占版本,再改覆盖值;两者只在同一短事务提交。
|
|
60
|
+
await session.flush()
|
|
61
|
+
row = await session.get(RuntimeSettingRow, name)
|
|
62
|
+
if value_json is None:
|
|
63
|
+
if row is not None:
|
|
64
|
+
await session.delete(row)
|
|
65
|
+
elif row is None:
|
|
66
|
+
session.add(RuntimeSettingRow(setting_name=name, value_json=value_json))
|
|
67
|
+
else:
|
|
68
|
+
row.value_json = value_json
|
|
69
|
+
return StoredSetting(value_json, version)
|
|
70
|
+
except (IntegrityError, StaleDataError):
|
|
71
|
+
raise ResourceConflictError("Setting version conflict") from None
|
|
72
|
+
except SQLAlchemyError as original_error:
|
|
73
|
+
raise _setting_error(name, "write", original_error) from None
|
|
74
|
+
|
|
75
|
+
def _session(self) -> PlatformAsyncSession:
|
|
76
|
+
session = PlatformAsyncSession(self._engines.get("default"), expire_on_commit=False)
|
|
77
|
+
_bind_current_user(session, self._runtime.current_user())
|
|
78
|
+
return session
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _setting_error(name: str, operation: str, error: SQLAlchemyError) -> RuntimeSettingError:
|
|
82
|
+
return RuntimeSettingError(
|
|
83
|
+
setting=name,
|
|
84
|
+
operation=operation,
|
|
85
|
+
error_type=type(error).__name__,
|
|
86
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""运行期设置持久化边界;实现负责独立短事务与版本条件写入。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from .values import StoredSetting
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RuntimeSettingStore(Protocol):
|
|
12
|
+
async def read(self, name: str) -> StoredSetting: ...
|
|
13
|
+
|
|
14
|
+
async def write(
|
|
15
|
+
self, name: str, value_json: str | None, *, expected_version: UUID | None
|
|
16
|
+
) -> StoredSetting: ...
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""设置存储与有效值的不可变快照;版本只表达数据库保存状态。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from ..notifications import NotificationPublishStatus
|
|
11
|
+
|
|
12
|
+
_T = TypeVar("_T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SettingValueSource(str, Enum):
|
|
16
|
+
RUNTIME = "runtime"
|
|
17
|
+
CONFIGURATION = "configuration"
|
|
18
|
+
DEFINITION = "definition"
|
|
19
|
+
UNSET = "unset"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class StoredSetting:
|
|
24
|
+
value_json: str | None
|
|
25
|
+
version: UUID | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class SettingSnapshot(Generic[_T]):
|
|
30
|
+
name: str
|
|
31
|
+
value: _T | None
|
|
32
|
+
source: SettingValueSource
|
|
33
|
+
version: UUID | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class SettingWriteOutcome(Generic[_T]):
|
|
38
|
+
snapshot: SettingSnapshot[_T]
|
|
39
|
+
notification: NotificationPublishStatus
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Optional SQLAlchemy/PostgreSQL persistence capability。"""
|
|
2
|
+
|
|
3
|
+
from .auditing import (
|
|
4
|
+
AuditedEntityMixin,
|
|
5
|
+
PlatformAsyncSession,
|
|
6
|
+
)
|
|
7
|
+
from .errors import (
|
|
8
|
+
MigrationHeadsMismatchError,
|
|
9
|
+
SqlAlchemyConnectionError,
|
|
10
|
+
SqlAlchemyMetadataChangedError,
|
|
11
|
+
SqlAlchemyMigrationError,
|
|
12
|
+
SqlAlchemyModelRegistrationError,
|
|
13
|
+
UnknownSqlAlchemyConnectionError,
|
|
14
|
+
)
|
|
15
|
+
from .metadata import (
|
|
16
|
+
SqlAlchemyMigrationContribution,
|
|
17
|
+
SqlAlchemyMigrationRegistration,
|
|
18
|
+
SqlAlchemyModelCatalog,
|
|
19
|
+
SqlAlchemyModelContribution,
|
|
20
|
+
SqlAlchemyModelRegistration,
|
|
21
|
+
contributes_sqlalchemy_migrations,
|
|
22
|
+
contributes_sqlalchemy_models,
|
|
23
|
+
)
|
|
24
|
+
from .migration import SqlAlchemyMigrator
|
|
25
|
+
from .module import SqlAlchemyPersistenceModule
|
|
26
|
+
from .options import ConnectionStringsOptions
|
|
27
|
+
from .repository import SqlAlchemyRepository
|
|
28
|
+
from .session_provider import SqlAlchemySessionProvider
|
|
29
|
+
|
|
30
|
+
__all__ = (
|
|
31
|
+
"AuditedEntityMixin",
|
|
32
|
+
"ConnectionStringsOptions",
|
|
33
|
+
"MigrationHeadsMismatchError",
|
|
34
|
+
"PlatformAsyncSession",
|
|
35
|
+
"SqlAlchemyConnectionError",
|
|
36
|
+
"SqlAlchemyMetadataChangedError",
|
|
37
|
+
"SqlAlchemyMigrationContribution",
|
|
38
|
+
"SqlAlchemyMigrationError",
|
|
39
|
+
"SqlAlchemyMigrationRegistration",
|
|
40
|
+
"SqlAlchemyMigrator",
|
|
41
|
+
"SqlAlchemyModelCatalog",
|
|
42
|
+
"SqlAlchemyModelContribution",
|
|
43
|
+
"SqlAlchemyModelRegistration",
|
|
44
|
+
"SqlAlchemyModelRegistrationError",
|
|
45
|
+
"SqlAlchemyPersistenceModule",
|
|
46
|
+
"SqlAlchemyRepository",
|
|
47
|
+
"SqlAlchemySessionProvider",
|
|
48
|
+
"UnknownSqlAlchemyConnectionError",
|
|
49
|
+
"contributes_sqlalchemy_migrations",
|
|
50
|
+
"contributes_sqlalchemy_models",
|
|
51
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Alembic runtime environment package;revision files remain Module-owned。"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Alembic environment using a Host-injected synchronous connection bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from alembic import context
|
|
6
|
+
from sqlalchemy import Connection, MetaData
|
|
7
|
+
|
|
8
|
+
config = context.config
|
|
9
|
+
connection = config.attributes.get("connection")
|
|
10
|
+
target_metadata = config.attributes.get("target_metadata")
|
|
11
|
+
|
|
12
|
+
if not isinstance(connection, Connection):
|
|
13
|
+
raise RuntimeError("Alembic runtime requires Config.attributes['connection']")
|
|
14
|
+
if not isinstance(target_metadata, tuple) or not all(
|
|
15
|
+
isinstance(item, MetaData) for item in target_metadata
|
|
16
|
+
):
|
|
17
|
+
raise RuntimeError("Alembic runtime requires immutable target_metadata")
|
|
18
|
+
|
|
19
|
+
context.configure(
|
|
20
|
+
connection=connection,
|
|
21
|
+
target_metadata=target_metadata,
|
|
22
|
+
include_schemas=True,
|
|
23
|
+
version_table="alembic_version",
|
|
24
|
+
include_name=config.attributes.get("include_name"),
|
|
25
|
+
)
|
|
26
|
+
with context.begin_transaction():
|
|
27
|
+
context.run_migrations()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""${message};请人工复核数据库变更与数据迁移。"""
|
|
2
|
+
from alembic import op
|
|
3
|
+
import sqlalchemy as sa
|
|
4
|
+
${imports if imports else ""}
|
|
5
|
+
revision = ${repr(up_revision)}
|
|
6
|
+
down_revision = ${repr(down_revision)}
|
|
7
|
+
branch_labels = ${repr(branch_labels)}
|
|
8
|
+
depends_on = ${repr(depends_on)}
|
|
9
|
+
|
|
10
|
+
def upgrade():
|
|
11
|
+
${upgrades if upgrades else "pass"}
|
|
12
|
+
|
|
13
|
+
def downgrade():
|
|
14
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""SQLAlchemy native events 维护审计字段;删除遵循原生 ORM 与外键语义。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import DateTime, Uuid, event
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
from sqlalchemy.orm import Mapped, Session, mapped_column
|
|
11
|
+
|
|
12
|
+
from ..authorization import CurrentUser
|
|
13
|
+
|
|
14
|
+
_CURRENT_USER_INFO_KEY = "python_platform_current_user"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuditedEntityMixin:
|
|
18
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
19
|
+
created_by: Mapped[UUID | None] = mapped_column(Uuid, nullable=True)
|
|
20
|
+
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
21
|
+
updated_by: Mapped[UUID | None] = mapped_column(Uuid, nullable=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _PlatformSyncSession(Session):
|
|
25
|
+
"""Listener 仅绑定 Platform 自有 Session class,不污染进程中的其他 SQLAlchemy usage。"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PlatformAsyncSession(AsyncSession):
|
|
29
|
+
sync_session_class = _PlatformSyncSession
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@event.listens_for(_PlatformSyncSession, "before_flush")
|
|
33
|
+
def _apply_audit_fields(session: Session, flush_context: object, instances: object) -> None:
|
|
34
|
+
current_user = session.info.get(_CURRENT_USER_INFO_KEY)
|
|
35
|
+
user_id = current_user.user_id if isinstance(current_user, CurrentUser) else None
|
|
36
|
+
now = datetime.now(UTC)
|
|
37
|
+
for entity in tuple(session.new):
|
|
38
|
+
if isinstance(entity, AuditedEntityMixin):
|
|
39
|
+
entity.created_at = now
|
|
40
|
+
entity.created_by = user_id
|
|
41
|
+
for entity in tuple(session.dirty):
|
|
42
|
+
if isinstance(entity, AuditedEntityMixin) and entity not in session.new:
|
|
43
|
+
entity.updated_at = now
|
|
44
|
+
entity.updated_by = user_id
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bind_current_user(session: PlatformAsyncSession, current_user: CurrentUser) -> None:
|
|
48
|
+
session.sync_session.info[_CURRENT_USER_INFO_KEY] = current_user
|