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,101 @@
|
|
|
1
|
+
"""真实 Host 工作流;先按 README 生成并审查各业务模块 revision。"""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import distribution
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dotenv import dotenv_values
|
|
7
|
+
from host.main import create_web
|
|
8
|
+
from host.module import {{ cookiecutter.class_prefix }}HostModule
|
|
9
|
+
from httpx import ASGITransport, AsyncClient
|
|
10
|
+
from testcontainers.community.postgres import PostgresContainer
|
|
11
|
+
from testcontainers.community.redis import RedisContainer
|
|
12
|
+
|
|
13
|
+
from python_platform import ApplicationBuilder, DataSeeder
|
|
14
|
+
from python_platform.sqlalchemy import SqlAlchemyMigrator
|
|
15
|
+
from python_platform.testing import TestApplication as PlatformTestApplication
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def test_host_http_workflow(monkeypatch):
|
|
19
|
+
configuration = dotenv_values(Path.cwd() / ".env")
|
|
20
|
+
with PostgresContainer(configuration["POSTGRES_IMAGE"], driver="asyncpg") as postgres:
|
|
21
|
+
with RedisContainer(configuration["REDIS_IMAGE"]) as redis:
|
|
22
|
+
monkeypatch.setenv(
|
|
23
|
+
"PP_CONNECTION_STRINGS__DEFAULT", postgres.get_connection_url(driver="asyncpg")
|
|
24
|
+
)
|
|
25
|
+
monkeypatch.setenv(
|
|
26
|
+
"PP_REDIS__URL",
|
|
27
|
+
f"redis://{redis.get_container_host_ip()}:{redis.get_exposed_port(redis.port)}/0",
|
|
28
|
+
)
|
|
29
|
+
test_application = PlatformTestApplication(
|
|
30
|
+
ApplicationBuilder(
|
|
31
|
+
{{ cookiecutter.class_prefix }}HostModule,
|
|
32
|
+
environment="development",
|
|
33
|
+
base_path=Path.cwd(),
|
|
34
|
+
env_prefix="PP_",
|
|
35
|
+
dotenv_path=Path.cwd() / ".env",
|
|
36
|
+
explicit_overrides={"logging": {"format": "text"}},
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
application = test_application.build()
|
|
40
|
+
try:
|
|
41
|
+
await SqlAlchemyMigrator(application).upgrade_heads()
|
|
42
|
+
await test_application.start()
|
|
43
|
+
await DataSeeder(application).seed_all()
|
|
44
|
+
finally:
|
|
45
|
+
await test_application.stop()
|
|
46
|
+
web = create_web()
|
|
47
|
+
async with web.router.lifespan_context(web):
|
|
48
|
+
async with AsyncClient(
|
|
49
|
+
transport=ASGITransport(web), base_url="http://test"
|
|
50
|
+
) as client:
|
|
51
|
+
assert (await client.get("/health/ready")).status_code == 200
|
|
52
|
+
login = await client.post(
|
|
53
|
+
"/api/auth/login",
|
|
54
|
+
json={
|
|
55
|
+
"username": configuration["PP_IDENTITY__SEED_ADMIN_USERNAME"],
|
|
56
|
+
"password": configuration["PP_IDENTITY__SEED_ADMIN_PASSWORD"],
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
assert login.status_code == 200
|
|
60
|
+
client.headers["Authorization"] = "Bearer " + login.json()["access_token"]
|
|
61
|
+
modules = (
|
|
62
|
+
entry.name
|
|
63
|
+
for entry in distribution("{{ cookiecutter.project_name }}").entry_points
|
|
64
|
+
if entry.group == "python_platform.modules"
|
|
65
|
+
)
|
|
66
|
+
for module in modules:
|
|
67
|
+
path = f"/api/{module}"
|
|
68
|
+
created = await client.post(path, json={"title": "Sample order"})
|
|
69
|
+
assert created.status_code == 201
|
|
70
|
+
order = created.json()
|
|
71
|
+
assert (await client.get(f"{path}/{order['id']}")).json() == order
|
|
72
|
+
approved = await client.post(
|
|
73
|
+
f"{path}/{order['id']}/approve",
|
|
74
|
+
json={"expected_version": order["version"]},
|
|
75
|
+
)
|
|
76
|
+
assert (
|
|
77
|
+
approved.status_code == 200 and approved.json()["status"] == "approved"
|
|
78
|
+
)
|
|
79
|
+
conflict = await client.post(
|
|
80
|
+
f"{path}/{order['id']}/approve",
|
|
81
|
+
json={"expected_version": order["version"]},
|
|
82
|
+
)
|
|
83
|
+
assert conflict.status_code == 409
|
|
84
|
+
assert (await client.get(f"{path}/{order['id']}")).json()[
|
|
85
|
+
"status"
|
|
86
|
+
] == "approved"
|
|
87
|
+
settings_path = f"/api/settings/{module}.approval_enabled"
|
|
88
|
+
setting = (await client.get(settings_path)).json()
|
|
89
|
+
assert (
|
|
90
|
+
await client.put(
|
|
91
|
+
settings_path,
|
|
92
|
+
json={"value": False, "expected_version": setting["version"]},
|
|
93
|
+
)
|
|
94
|
+
).status_code == 200
|
|
95
|
+
second = (await client.post(path, json={"title": "Second"})).json()
|
|
96
|
+
assert (
|
|
97
|
+
await client.post(
|
|
98
|
+
f"{path}/{second['id']}/approve",
|
|
99
|
+
json={"expected_version": second["version"]},
|
|
100
|
+
)
|
|
101
|
+
).status_code == 400
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""LibCST 只修改已识别 startup class 的依赖;不重写用户文件的格式和注释。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import libcst as cst
|
|
8
|
+
import libcst.matchers as matchers
|
|
9
|
+
from libcst.codemod import CodemodContext
|
|
10
|
+
from libcst.codemod.visitors import AddImportsVisitor
|
|
11
|
+
from libcst.metadata import ClassScope, MetadataWrapper, ScopeProvider
|
|
12
|
+
|
|
13
|
+
from ..cli.errors import CommandError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _AddDependency(cst.CSTTransformer):
|
|
17
|
+
def __init__(self, startup: cst.ClassDef, dependency: str) -> None:
|
|
18
|
+
self._startup = startup
|
|
19
|
+
self._dependency = dependency
|
|
20
|
+
self.matches = 0
|
|
21
|
+
|
|
22
|
+
def leave_ClassDef(
|
|
23
|
+
self, original_node: cst.ClassDef, updated_node: cst.ClassDef
|
|
24
|
+
) -> cst.ClassDef:
|
|
25
|
+
if original_node is not self._startup:
|
|
26
|
+
return updated_node
|
|
27
|
+
body = cast(cst.IndentedBlock, updated_node.body)
|
|
28
|
+
statements = list(body.body)
|
|
29
|
+
for index, statement in enumerate(statements):
|
|
30
|
+
if not isinstance(statement, cst.SimpleStatementLine) or len(statement.body) != 1:
|
|
31
|
+
continue
|
|
32
|
+
assignment = statement.body[0]
|
|
33
|
+
if isinstance(assignment, cst.Assign) and len(assignment.targets) == 1:
|
|
34
|
+
target = assignment.targets[0].target
|
|
35
|
+
elif isinstance(assignment, cst.AnnAssign):
|
|
36
|
+
target = assignment.target
|
|
37
|
+
else:
|
|
38
|
+
continue
|
|
39
|
+
if not isinstance(target, cst.Name) or target.value != "dependencies":
|
|
40
|
+
continue
|
|
41
|
+
value = assignment.value
|
|
42
|
+
if not isinstance(value, cst.Tuple) or any(
|
|
43
|
+
isinstance(item, cst.StarredElement) for item in value.elements
|
|
44
|
+
):
|
|
45
|
+
raise CommandError(
|
|
46
|
+
"Startup dependencies must be an explicit tuple without unpacking"
|
|
47
|
+
)
|
|
48
|
+
self.matches += 1
|
|
49
|
+
replacement = value.with_changes(
|
|
50
|
+
elements=(*value.elements, cst.Element(cst.Name(self._dependency)))
|
|
51
|
+
)
|
|
52
|
+
statements[index] = statement.with_changes(
|
|
53
|
+
body=(assignment.with_changes(value=replacement),)
|
|
54
|
+
)
|
|
55
|
+
return updated_node.with_changes(body=body.with_changes(body=statements))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def add_dependency(source: str, startup: str, import_path: str, dependency: str) -> str:
|
|
59
|
+
wrapper = MetadataWrapper(cst.parse_module(source))
|
|
60
|
+
module = wrapper.module
|
|
61
|
+
# 星号导出的绑定无法由静态 scope 确定,不能冒险覆盖用户的既有依赖。
|
|
62
|
+
if matchers.findall(module, matchers.ImportStar()):
|
|
63
|
+
raise CommandError("Star imports prevent safe startup dependency wiring")
|
|
64
|
+
# 原生作用域绑定防止覆盖用户符号;仅修改实际顶层节点,不触及嵌套同名类。
|
|
65
|
+
candidates = tuple(
|
|
66
|
+
statement
|
|
67
|
+
for statement in module.body
|
|
68
|
+
if isinstance(statement, cst.ClassDef) and statement.name.value == startup
|
|
69
|
+
)
|
|
70
|
+
if len(candidates) != 1:
|
|
71
|
+
raise CommandError("Startup Module must be a unique top-level class")
|
|
72
|
+
candidate = candidates[0]
|
|
73
|
+
if not isinstance(candidate.body, cst.IndentedBlock):
|
|
74
|
+
raise CommandError("Startup Module must use an indented class body")
|
|
75
|
+
# 类体的名称查找包含本类和外层模块;两层绑定均不得被新增裸名称遮蔽。
|
|
76
|
+
scope = cast(ClassScope, wrapper.resolve(ScopeProvider)[candidate.body.body[0]])
|
|
77
|
+
if dependency in scope:
|
|
78
|
+
raise CommandError(f"Dependency name already bound in startup module: {dependency}")
|
|
79
|
+
transformer = _AddDependency(candidate, dependency)
|
|
80
|
+
updated = module.visit(transformer)
|
|
81
|
+
if transformer.matches != 1:
|
|
82
|
+
raise CommandError("Cannot uniquely identify the startup Module dependencies assignment")
|
|
83
|
+
context = CodemodContext()
|
|
84
|
+
AddImportsVisitor.add_needed_import(context, import_path, dependency)
|
|
85
|
+
return AddImportsVisitor(context).transform_module(updated).code
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Diagnostic contracts 与 Application-owned journal 的 capability 边界。"""
|
|
2
|
+
|
|
3
|
+
from .model import (
|
|
4
|
+
DiagnosticMessage,
|
|
5
|
+
DiagnosticSnapshot,
|
|
6
|
+
EventStatus,
|
|
7
|
+
FailureRecord,
|
|
8
|
+
LifecyclePhase,
|
|
9
|
+
LifecycleStage,
|
|
10
|
+
PhaseEvent,
|
|
11
|
+
SourceLocation,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = (
|
|
15
|
+
"DiagnosticMessage",
|
|
16
|
+
"DiagnosticSnapshot",
|
|
17
|
+
"EventStatus",
|
|
18
|
+
"FailureRecord",
|
|
19
|
+
"LifecyclePhase",
|
|
20
|
+
"LifecycleStage",
|
|
21
|
+
"PhaseEvent",
|
|
22
|
+
"SourceLocation",
|
|
23
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""单个 Application 拥有的 append-only diagnostic journal。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from time import perf_counter_ns
|
|
7
|
+
from uuid import UUID, uuid4
|
|
8
|
+
|
|
9
|
+
from ..errors import InvalidDiagnosticCodeError
|
|
10
|
+
from .model import (
|
|
11
|
+
DiagnosticMessage,
|
|
12
|
+
DiagnosticSnapshot,
|
|
13
|
+
EventStatus,
|
|
14
|
+
FailureRecord,
|
|
15
|
+
LifecyclePhase,
|
|
16
|
+
LifecycleStage,
|
|
17
|
+
PhaseEvent,
|
|
18
|
+
SourceLocation,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_DIAGNOSTIC_CODE_PUNCTUATION = frozenset("._-")
|
|
22
|
+
_MAX_DIAGNOSTIC_CODE_LENGTH = 128
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class _ActiveEvent:
|
|
27
|
+
phase: LifecyclePhase
|
|
28
|
+
stage: LifecycleStage
|
|
29
|
+
subject: str
|
|
30
|
+
source: SourceLocation | None
|
|
31
|
+
started_ns: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _DiagnosticJournal:
|
|
35
|
+
"""Application-owned journal;外部只能取得不可变的时点快照。"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_events", "_failure_order", "_failures", "_messages", "_sequence")
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._sequence = 0
|
|
41
|
+
self._events: list[PhaseEvent] = []
|
|
42
|
+
self._messages: list[DiagnosticMessage] = []
|
|
43
|
+
self._failure_order: list[UUID] = []
|
|
44
|
+
self._failures: dict[UUID, FailureRecord] = {}
|
|
45
|
+
|
|
46
|
+
def _next_sequence(self) -> int:
|
|
47
|
+
self._sequence += 1
|
|
48
|
+
return self._sequence
|
|
49
|
+
|
|
50
|
+
def start_event(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
phase: LifecyclePhase,
|
|
54
|
+
stage: LifecycleStage,
|
|
55
|
+
subject: str,
|
|
56
|
+
source: SourceLocation | None,
|
|
57
|
+
) -> _ActiveEvent:
|
|
58
|
+
started_ns = perf_counter_ns()
|
|
59
|
+
active_event = _ActiveEvent(phase, stage, subject, source, started_ns)
|
|
60
|
+
self._events.append(
|
|
61
|
+
PhaseEvent(
|
|
62
|
+
sequence=self._next_sequence(),
|
|
63
|
+
phase=phase,
|
|
64
|
+
stage=stage,
|
|
65
|
+
subject=subject,
|
|
66
|
+
status=EventStatus.STARTED,
|
|
67
|
+
started_monotonic_ns=started_ns,
|
|
68
|
+
duration_ns=None,
|
|
69
|
+
source=source,
|
|
70
|
+
failure_id=None,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return active_event
|
|
74
|
+
|
|
75
|
+
def finish_event(
|
|
76
|
+
self,
|
|
77
|
+
active_event: _ActiveEvent,
|
|
78
|
+
*,
|
|
79
|
+
status: EventStatus,
|
|
80
|
+
failure_id: UUID | None,
|
|
81
|
+
) -> int:
|
|
82
|
+
# start 返回的 value object 是一次事件的唯一事实,finish 不重复传递上下文。
|
|
83
|
+
duration_ns = perf_counter_ns() - active_event.started_ns
|
|
84
|
+
self._events.append(
|
|
85
|
+
PhaseEvent(
|
|
86
|
+
sequence=self._next_sequence(),
|
|
87
|
+
phase=active_event.phase,
|
|
88
|
+
stage=active_event.stage,
|
|
89
|
+
subject=active_event.subject,
|
|
90
|
+
status=status,
|
|
91
|
+
started_monotonic_ns=active_event.started_ns,
|
|
92
|
+
duration_ns=duration_ns,
|
|
93
|
+
source=active_event.source,
|
|
94
|
+
failure_id=failure_id,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return duration_ns
|
|
98
|
+
|
|
99
|
+
def begin_failure(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
error_type: str,
|
|
103
|
+
phase: LifecyclePhase,
|
|
104
|
+
stage: LifecycleStage,
|
|
105
|
+
subject: str | None,
|
|
106
|
+
message: str,
|
|
107
|
+
source: SourceLocation | None,
|
|
108
|
+
dependency_path: tuple[str, ...],
|
|
109
|
+
) -> UUID:
|
|
110
|
+
failure_id = uuid4()
|
|
111
|
+
# 先发布不可变记录,rollback 后替换 cleanup 关联,snapshot 不出现悬空引用。
|
|
112
|
+
failure = FailureRecord(
|
|
113
|
+
id=failure_id,
|
|
114
|
+
error_type=error_type,
|
|
115
|
+
phase=phase,
|
|
116
|
+
stage=stage,
|
|
117
|
+
subject=subject,
|
|
118
|
+
message=message,
|
|
119
|
+
source=source,
|
|
120
|
+
dependency_path=dependency_path,
|
|
121
|
+
cleanup_failure_ids=(),
|
|
122
|
+
)
|
|
123
|
+
self._failure_order.append(failure_id)
|
|
124
|
+
self._failures[failure_id] = failure
|
|
125
|
+
return failure_id
|
|
126
|
+
|
|
127
|
+
def complete_failure(
|
|
128
|
+
self,
|
|
129
|
+
failure_id: UUID,
|
|
130
|
+
*,
|
|
131
|
+
cleanup_failure_ids: tuple[UUID, ...] = (),
|
|
132
|
+
) -> None:
|
|
133
|
+
self._failures[failure_id] = replace(
|
|
134
|
+
self._failures[failure_id], cleanup_failure_ids=cleanup_failure_ids
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def emit(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
phase: LifecyclePhase,
|
|
141
|
+
stage: LifecycleStage,
|
|
142
|
+
subject: str,
|
|
143
|
+
code: str,
|
|
144
|
+
source: SourceLocation | None,
|
|
145
|
+
) -> None:
|
|
146
|
+
if not _is_diagnostic_code(code):
|
|
147
|
+
raise InvalidDiagnosticCodeError(code, max_length=_MAX_DIAGNOSTIC_CODE_LENGTH)
|
|
148
|
+
self._messages.append(
|
|
149
|
+
DiagnosticMessage(
|
|
150
|
+
sequence=self._next_sequence(),
|
|
151
|
+
phase=phase,
|
|
152
|
+
stage=stage,
|
|
153
|
+
subject=subject,
|
|
154
|
+
code=code,
|
|
155
|
+
source=source,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def snapshot(self, application_id: UUID) -> DiagnosticSnapshot:
|
|
160
|
+
failures = tuple(self._failures[failure_id] for failure_id in self._failure_order)
|
|
161
|
+
return DiagnosticSnapshot(
|
|
162
|
+
application_id=application_id,
|
|
163
|
+
events=tuple(self._events),
|
|
164
|
+
failures=failures,
|
|
165
|
+
messages=tuple(self._messages),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_diagnostic_code(code: str) -> bool:
|
|
170
|
+
if not code or len(code) > _MAX_DIAGNOSTIC_CODE_LENGTH or not code[0].isalpha():
|
|
171
|
+
return False
|
|
172
|
+
return all(
|
|
173
|
+
character.isalnum() or character in _DIAGNOSTIC_CODE_PUNCTUATION for character in code
|
|
174
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""不可变 diagnostic contract;不拥有 journal mutation。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LifecyclePhase(str, Enum):
|
|
11
|
+
COMPOSITION = "composition"
|
|
12
|
+
RUNTIME = "runtime"
|
|
13
|
+
CLEANUP = "cleanup"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LifecycleStage(str, Enum):
|
|
17
|
+
PRE_CONFIGURE = "pre_configure"
|
|
18
|
+
CONFIGURE = "configure"
|
|
19
|
+
POST_CONFIGURE = "post_configure"
|
|
20
|
+
PRE_INITIALIZE = "pre_initialize"
|
|
21
|
+
INITIALIZE = "initialize"
|
|
22
|
+
POST_INITIALIZE = "post_initialize"
|
|
23
|
+
SHUTDOWN = "shutdown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EventStatus(str, Enum):
|
|
27
|
+
STARTED = "started"
|
|
28
|
+
SUCCEEDED = "succeeded"
|
|
29
|
+
FAILED = "failed"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class SourceLocation:
|
|
34
|
+
owner: str
|
|
35
|
+
file: str | None
|
|
36
|
+
line: int | None
|
|
37
|
+
symbol: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class PhaseEvent:
|
|
42
|
+
sequence: int
|
|
43
|
+
phase: LifecyclePhase
|
|
44
|
+
stage: LifecycleStage
|
|
45
|
+
subject: str
|
|
46
|
+
status: EventStatus
|
|
47
|
+
started_monotonic_ns: int
|
|
48
|
+
duration_ns: int | None
|
|
49
|
+
source: SourceLocation | None
|
|
50
|
+
failure_id: UUID | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class FailureRecord:
|
|
55
|
+
id: UUID
|
|
56
|
+
error_type: str
|
|
57
|
+
phase: LifecyclePhase
|
|
58
|
+
stage: LifecycleStage
|
|
59
|
+
subject: str | None
|
|
60
|
+
message: str
|
|
61
|
+
source: SourceLocation | None
|
|
62
|
+
dependency_path: tuple[str, ...]
|
|
63
|
+
cleanup_failure_ids: tuple[UUID, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class DiagnosticMessage:
|
|
68
|
+
sequence: int
|
|
69
|
+
phase: LifecyclePhase
|
|
70
|
+
stage: LifecycleStage
|
|
71
|
+
subject: str
|
|
72
|
+
code: str
|
|
73
|
+
source: SourceLocation | None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class DiagnosticSnapshot:
|
|
78
|
+
"""一次读取产生的不可变诊断视图,不随后续 lifecycle 变化。"""
|
|
79
|
+
|
|
80
|
+
application_id: UUID
|
|
81
|
+
events: tuple[PhaseEvent, ...]
|
|
82
|
+
failures: tuple[FailureRecord, ...]
|
|
83
|
+
messages: tuple[DiagnosticMessage, ...]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""贡献诊断的调用位置;depth 由公开贡献入口指定。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
from .model import SourceLocation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def caller_source(owner: str, *, depth: int) -> SourceLocation:
|
|
11
|
+
frame = inspect.currentframe()
|
|
12
|
+
try:
|
|
13
|
+
for _ in range(depth):
|
|
14
|
+
frame = None if frame is None else frame.f_back
|
|
15
|
+
if frame is None:
|
|
16
|
+
return SourceLocation(owner=owner, file=None, line=None, symbol=None)
|
|
17
|
+
return SourceLocation(
|
|
18
|
+
owner=owner,
|
|
19
|
+
file=frame.f_code.co_filename,
|
|
20
|
+
line=frame.f_lineno,
|
|
21
|
+
symbol=frame.f_code.co_name,
|
|
22
|
+
)
|
|
23
|
+
finally:
|
|
24
|
+
# 不让 frame 引用延长 Module/composition 对象的生命周期。
|
|
25
|
+
del frame
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""锁的业务使用与错误边界;provider 不进入稳定契约。"""
|
|
2
|
+
|
|
3
|
+
from contextlib import AbstractAsyncContextManager
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from ..errors.base import FrameworkError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DistributedLock(Protocol):
|
|
10
|
+
def acquire(self, key: str, /) -> AbstractAsyncContextManager[bool]:
|
|
11
|
+
"""进入后返回是否取得锁;持有者必须配合取消并在退出上下文前完成清理。"""
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DistributedLockError(FrameworkError):
|
|
16
|
+
def __init__(self, *, key: str, operation: str, error_type: str) -> None:
|
|
17
|
+
self.key = key
|
|
18
|
+
self.operation = operation
|
|
19
|
+
self.error_type = error_type
|
|
20
|
+
super().__init__(f"Distributed lock {key!r} {operation} failed with {error_type}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""获取等待和租约时序的唯一默认值来源。"""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, model_validator
|
|
6
|
+
|
|
7
|
+
from ..options import BaseOptions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DistributedLockOptions(BaseOptions):
|
|
11
|
+
wait_timeout: timedelta = Field(default=timedelta(seconds=5), ge=timedelta(0))
|
|
12
|
+
lease_timeout: timedelta = Field(default=timedelta(seconds=30), ge=timedelta(milliseconds=1))
|
|
13
|
+
renewal_interval: timedelta = Field(default=timedelta(seconds=10), ge=timedelta(milliseconds=1))
|
|
14
|
+
|
|
15
|
+
@model_validator(mode="after")
|
|
16
|
+
def _validate_renewal(self) -> "DistributedLockOptions":
|
|
17
|
+
if self.renewal_interval >= self.lease_timeout:
|
|
18
|
+
raise ValueError("renewal_interval must be shorter than lease_timeout")
|
|
19
|
+
return self
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Provider-neutral DDD foundations owned by Python Platform."""
|
|
2
|
+
|
|
3
|
+
from .aggregates import AggregateRoot
|
|
4
|
+
from .entities import Entity
|
|
5
|
+
from .errors import OptimisticConcurrencyError
|
|
6
|
+
from .value_objects import ValueObject
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"AggregateRoot",
|
|
10
|
+
"Entity",
|
|
11
|
+
"OptimisticConcurrencyError",
|
|
12
|
+
"ValueObject",
|
|
13
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Aggregate identity, version and pending local-event ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import is_dataclass
|
|
6
|
+
from typing import Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from .entities import Entity
|
|
11
|
+
from .errors import OptimisticConcurrencyError
|
|
12
|
+
|
|
13
|
+
_TId = TypeVar("_TId")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AggregateRoot(Entity[_TId], Generic[_TId]):
|
|
17
|
+
"""一个 invocation 内聚合版本和待提交事件的唯一 mutable owner。"""
|
|
18
|
+
|
|
19
|
+
__slots__ = ("_local_events", "_version", "_version_staged")
|
|
20
|
+
|
|
21
|
+
def __init__(self, entity_id: _TId, *, version: int = 0) -> None:
|
|
22
|
+
super().__init__(entity_id)
|
|
23
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 0:
|
|
24
|
+
raise ValueError("aggregate version must be a non-negative integer")
|
|
25
|
+
self._version = version
|
|
26
|
+
self._version_staged = False
|
|
27
|
+
self._local_events: list[object] = []
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def version(self) -> int:
|
|
31
|
+
return self._version
|
|
32
|
+
|
|
33
|
+
def raise_local_event(self, event: object) -> None:
|
|
34
|
+
if not _is_immutable_event(event):
|
|
35
|
+
raise TypeError("aggregate local events must be immutable dataclass or Pydantic values")
|
|
36
|
+
self._local_events.append(event)
|
|
37
|
+
|
|
38
|
+
def release_local_events(self) -> tuple[object, ...]:
|
|
39
|
+
events = tuple(self._local_events)
|
|
40
|
+
self._local_events.clear()
|
|
41
|
+
return events
|
|
42
|
+
|
|
43
|
+
def stage_next_version(self, expected_version: int) -> int:
|
|
44
|
+
"""Repository SPI:一次聚合实例只允许暂存一个下一版本。"""
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
not isinstance(expected_version, int)
|
|
48
|
+
or isinstance(expected_version, bool)
|
|
49
|
+
or expected_version < 0
|
|
50
|
+
):
|
|
51
|
+
raise ValueError("expected_version must be a non-negative integer")
|
|
52
|
+
if self._version_staged or self._version != expected_version:
|
|
53
|
+
raise OptimisticConcurrencyError
|
|
54
|
+
self._version += 1
|
|
55
|
+
self._version_staged = True
|
|
56
|
+
return self._version
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_immutable_event(event: object) -> bool:
|
|
60
|
+
event_type = type(event)
|
|
61
|
+
dataclass_parameters = getattr(event_type, "__dataclass_params__", None)
|
|
62
|
+
if is_dataclass(event) and bool(getattr(dataclass_parameters, "frozen", False)):
|
|
63
|
+
return True
|
|
64
|
+
return isinstance(event, BaseModel) and event.model_config.get("frozen") is True
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Entity identity semantics independent from persistence providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
_TId = TypeVar("_TId")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Entity(Generic[_TId]):
|
|
11
|
+
"""Identity owner whose identifier cannot change after construction."""
|
|
12
|
+
|
|
13
|
+
__slots__ = ("_id",)
|
|
14
|
+
|
|
15
|
+
def __init__(self, entity_id: _TId) -> None:
|
|
16
|
+
if entity_id is None:
|
|
17
|
+
raise ValueError("entity id cannot be None")
|
|
18
|
+
self._id = entity_id
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def id(self) -> _TId:
|
|
22
|
+
return self._id
|
|
23
|
+
|
|
24
|
+
def __eq__(self, other: object) -> bool:
|
|
25
|
+
if self is other:
|
|
26
|
+
return True
|
|
27
|
+
if type(self) is not type(other):
|
|
28
|
+
return False
|
|
29
|
+
return self.id == other.id
|
|
30
|
+
|
|
31
|
+
def __hash__(self) -> int:
|
|
32
|
+
# 类型是 identity 的一部分,避免不同聚合偶然共用相同标识时发生碰撞语义。
|
|
33
|
+
return hash((type(self), self.id))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""DDD boundary failures safe for transport mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..errors import ResourceConflictError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OptimisticConcurrencyError(ResourceConflictError):
|
|
9
|
+
"""调用方版本与持久化 authority 不一致;不携带真实行版本或 SQL。"""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
super().__init__("Aggregate version conflict")
|