python-ddd-framework 0.5.1__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_ddd_framework/application_services/bindings.py +0 -64
- python_ddd_framework/application_services/dispatcher.py +1 -1
- python_ddd_framework/application_services/invocation.py +7 -563
- python_ddd_framework/application_services/module.py +16 -5
- python_ddd_framework/authorization/checking.py +35 -0
- python_ddd_framework/authorization/module.py +1 -1
- python_ddd_framework/background_jobs/execution.py +1 -1
- python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
- python_ddd_framework/background_workers/execution.py +1 -1
- python_ddd_framework/background_workers/runtime.py +1 -1
- python_ddd_framework/caching/unit_of_work.py +2 -4
- python_ddd_framework/cli/project.py +11 -10
- python_ddd_framework/developer_kit/generation.py +44 -43
- python_ddd_framework/developer_kit/publication.py +442 -0
- python_ddd_framework/developer_kit/templates/module/cookiecutter.json +1 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +34 -44
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +5 -72
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +1 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +2 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +8 -62
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +1 -2
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +23 -8
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +22 -10
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/backend/src/host/main.py.jinja +4 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +55 -5
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +233 -25
- python_ddd_framework/diagnostics/source.py +14 -1
- python_ddd_framework/domain/aggregates.py +5 -12
- python_ddd_framework/domain/event_values.py +124 -0
- python_ddd_framework/events/runtime.py +2 -7
- python_ddd_framework/events/unit_of_work.py +1 -4
- python_ddd_framework/fastapi/__init__.py +2 -0
- python_ddd_framework/fastapi/access.py +127 -0
- python_ddd_framework/fastapi/access_options.py +13 -0
- python_ddd_framework/fastapi/action.py +8 -1
- python_ddd_framework/fastapi/adapter.py +11 -117
- python_ddd_framework/fastapi/application_services.py +17 -402
- python_ddd_framework/fastapi/error_handlers.py +128 -0
- python_ddd_framework/fastapi/manual_action.py +33 -2
- python_ddd_framework/fastapi/module.py +2 -0
- python_ddd_framework/fastapi/realtime/authentication.py +21 -10
- python_ddd_framework/fastapi/realtime/options.py +0 -1
- python_ddd_framework/fastapi/realtime/runtime.py +42 -27
- python_ddd_framework/fastapi/request_context.py +12 -7
- python_ddd_framework/fastapi/routing.py +16 -15
- python_ddd_framework/fastapi/server.py +9 -0
- python_ddd_framework/fastapi/service_endpoints.py +187 -0
- python_ddd_framework/fastapi/service_routes.py +237 -0
- python_ddd_framework/fastapi/streaming.py +95 -0
- python_ddd_framework/fastapi/transfer.py +139 -4
- python_ddd_framework/hosted_services/bridge.py +2 -6
- python_ddd_framework/hosted_services/runtime.py +1 -1
- python_ddd_framework/identity/__init__.py +21 -13
- python_ddd_framework/identity/application.py +12 -66
- python_ddd_framework/identity/contracts.py +38 -411
- python_ddd_framework/identity/domain_rules.py +17 -0
- python_ddd_framework/identity/dtos.py +152 -0
- python_ddd_framework/identity/extensions.py +9 -0
- python_ddd_framework/identity/management.py +28 -25
- python_ddd_framework/identity/models.py +86 -0
- python_ddd_framework/identity/module.py +3 -4
- python_ddd_framework/identity/options.py +54 -0
- python_ddd_framework/identity/seeding.py +59 -0
- python_ddd_framework/identity/services.py +1 -1
- python_ddd_framework/identity/sqlalchemy/module.py +9 -6
- python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
- python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
- python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
- python_ddd_framework/identity/stores.py +120 -0
- python_ddd_framework/identity/tokens.py +1 -1
- python_ddd_framework/invocation/callables.py +1 -1
- python_ddd_framework/invocation/dispatcher.py +3 -25
- python_ddd_framework/invocation/entrypoints.py +2 -1
- python_ddd_framework/invocation/function_runtime.py +2 -2
- python_ddd_framework/invocation/interception.py +1 -1
- python_ddd_framework/invocation/managed_proxy.py +2 -1
- python_ddd_framework/invocation/managed_services.py +8 -14
- python_ddd_framework/invocation/methods.py +2 -7
- python_ddd_framework/invocation/module.py +1 -1
- python_ddd_framework/invocation/runtime.py +483 -0
- python_ddd_framework/invocation/scopes.py +93 -0
- python_ddd_framework/messaging/channel.py +1 -1
- python_ddd_framework/messaging/runtime.py +1 -1
- python_ddd_framework/modularity/discovery.py +2 -7
- python_ddd_framework/modularity/graph.py +2 -8
- python_ddd_framework/observability/formatting.py +1 -0
- python_ddd_framework/observability/logging.py +1 -1
- python_ddd_framework/observability/tracing.py +1 -1
- python_ddd_framework/redis/cache.py +2 -0
- python_ddd_framework/redis/notification_runtime.py +1 -1
- python_ddd_framework/services/arbitration.py +1 -20
- python_ddd_framework/services/composition.py +71 -2
- python_ddd_framework/services/provider.py +23 -1
- python_ddd_framework/services/relocation.py +96 -0
- python_ddd_framework/settings/refresh.py +1 -1
- python_ddd_framework/settings/sqlalchemy/store.py +1 -1
- python_ddd_framework/sqlalchemy/metadata.py +11 -413
- python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
- python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
- python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
- python_ddd_framework/sqlalchemy/migration.py +2 -1
- python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
- python_ddd_framework/sqlalchemy/module.py +2 -1
- python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
- python_ddd_framework/unit_of_work/contracts.py +68 -7
- python_ddd_framework/unit_of_work/manager.py +15 -9
- python_ddd_framework/unit_of_work/module.py +1 -1
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/METADATA +101 -10
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/RECORD +113 -141
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/WHEEL +1 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +0 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +0 -45
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +0 -8
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +0 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +0 -35
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +0 -5
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +0 -15
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +0 -28
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +0 -26
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +0 -23
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +0 -71
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +0 -16
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +0 -16
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/order_options.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +0 -65
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +0 -35
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +0 -41
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +0 -6
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +0 -10
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +0 -12
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +0 -12
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/order.py.jinja +0 -48
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +0 -22
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +0 -23
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/approval_settings.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +0 -10
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +0 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +0 -6
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +0 -11
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +0 -26
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permission_provider.py.jinja +0 -19
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +0 -18
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +0 -15
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +0 -5
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/order_files.py.jinja +0 -76
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +0 -44
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/order_model.py.jinja +0 -29
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +0 -79
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +0 -19
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""一次模块生成的发布现场;记录事实并指导手动恢复,不自动补偿。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib.metadata as metadata
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Literal
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
from filelock import FileLock, Timeout
|
|
21
|
+
|
|
22
|
+
from ..cli.errors import CommandError
|
|
23
|
+
from ..cli.project import _PROJECT_ENVIRONMENT_OVERRIDES, project_environment
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _digest(content: bytes) -> str:
|
|
27
|
+
return hashlib.sha256(content).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _content(path: Path) -> bytes | None:
|
|
31
|
+
if path.is_symlink() or path.is_junction():
|
|
32
|
+
raise CommandError(f"Refusing linked publication target: {path}")
|
|
33
|
+
return path.read_bytes() if path.exists() else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _tree(root: Path) -> dict[str, str]:
|
|
37
|
+
if root.is_symlink() or root.is_junction() or not root.is_dir():
|
|
38
|
+
raise CommandError(f"Expected an ordinary module directory: {root}")
|
|
39
|
+
result: dict[str, str] = {}
|
|
40
|
+
directories = [root]
|
|
41
|
+
# iterdir 的读取错误必须传播;不能把无法枚举的目录当作已完整核对的空目录。
|
|
42
|
+
while directories:
|
|
43
|
+
for path in sorted(directories.pop().iterdir()):
|
|
44
|
+
if path.is_symlink() or path.is_junction():
|
|
45
|
+
raise CommandError(f"Unexpected link in module directory: {path}")
|
|
46
|
+
if path.is_dir():
|
|
47
|
+
result[path.relative_to(root).as_posix()] = "directory"
|
|
48
|
+
directories.append(path)
|
|
49
|
+
else:
|
|
50
|
+
result[path.relative_to(root).as_posix()] = _digest(path.read_bytes())
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class _FileChange:
|
|
56
|
+
name: str
|
|
57
|
+
target: Path
|
|
58
|
+
original: bytes | None
|
|
59
|
+
proposed: bytes
|
|
60
|
+
pending: Path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _ModulePublication:
|
|
64
|
+
def __init__(self, root: Path, package: str, template: Literal["basic", "ddd"]) -> None:
|
|
65
|
+
self.root = root.resolve()
|
|
66
|
+
self.operation_id = uuid4().hex
|
|
67
|
+
self.recovery_root = self.root / ".python-ddd-framework" / "generation"
|
|
68
|
+
self.recovery = self.recovery_root / self.operation_id
|
|
69
|
+
self.rendering = Path(tempfile.gettempdir()) / f"pddd-add-{self.operation_id}"
|
|
70
|
+
self.destination = self.root / "src" / "modules" / package
|
|
71
|
+
self.pending = self.destination.with_name(f".pddd-{self.operation_id}")
|
|
72
|
+
self.template = template
|
|
73
|
+
self._lock = FileLock(
|
|
74
|
+
self.recovery_root.parent / "generation.lock",
|
|
75
|
+
timeout=0,
|
|
76
|
+
fallback_to_soft=False,
|
|
77
|
+
preserve_lock_file=True,
|
|
78
|
+
)
|
|
79
|
+
self._changes: tuple[_FileChange, ...] = ()
|
|
80
|
+
self._module_files: dict[str, str] = {}
|
|
81
|
+
self._current_step = "prepare"
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> _ModulePublication:
|
|
84
|
+
try:
|
|
85
|
+
self._lock.acquire()
|
|
86
|
+
except Timeout as error:
|
|
87
|
+
raise CommandError(
|
|
88
|
+
f"Another module generation owns {self.root}; inspect {self.recovery_root}"
|
|
89
|
+
) from error
|
|
90
|
+
try:
|
|
91
|
+
print(f"Module recovery files: {self.recovery}", flush=True)
|
|
92
|
+
self.recovery.mkdir(parents=True)
|
|
93
|
+
self._write_json(
|
|
94
|
+
"operation.json",
|
|
95
|
+
{
|
|
96
|
+
"operation_id": self.operation_id,
|
|
97
|
+
"project_root": str(self.root),
|
|
98
|
+
"distribution": "python-ddd-framework",
|
|
99
|
+
"version": metadata.version("python-ddd-framework"),
|
|
100
|
+
"template": self.template,
|
|
101
|
+
"rendering": str(self.rendering),
|
|
102
|
+
"destination": str(self.destination),
|
|
103
|
+
"pending_module": str(self.pending),
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
self.recovery.joinpath("RECOVERY.md").write_text(
|
|
107
|
+
_RECOVERY.replace("<RECOVERY_PATH>", repr(self.recovery.as_posix())),
|
|
108
|
+
encoding="utf-8",
|
|
109
|
+
)
|
|
110
|
+
except BaseException:
|
|
111
|
+
self._lock.release()
|
|
112
|
+
print(f"Preparation failed; retained files: {self.recovery}", file=sys.stderr)
|
|
113
|
+
raise
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(
|
|
117
|
+
self,
|
|
118
|
+
exc_type: type[BaseException] | None,
|
|
119
|
+
exc: BaseException | None,
|
|
120
|
+
traceback: TracebackType | None,
|
|
121
|
+
) -> None:
|
|
122
|
+
try:
|
|
123
|
+
if exc is not None:
|
|
124
|
+
self._report_failure(exc)
|
|
125
|
+
finally:
|
|
126
|
+
self._lock.release()
|
|
127
|
+
|
|
128
|
+
def _write_json(self, name: str, value: object) -> None:
|
|
129
|
+
# 计划只写一次;写入失败留下原片段,不能带着不完整计划开始发布。
|
|
130
|
+
with self.recovery.joinpath(name).open("x", encoding="utf-8") as stream:
|
|
131
|
+
json.dump(value, stream, ensure_ascii=True, indent=2)
|
|
132
|
+
stream.write("\n")
|
|
133
|
+
|
|
134
|
+
def _record(self, step: str, state: str, *, returncode: int | None = None) -> None:
|
|
135
|
+
with self.recovery.joinpath("steps.jsonl").open("a", encoding="utf-8") as stream:
|
|
136
|
+
stream.write(json.dumps({"step": step, "state": state, "returncode": returncode}))
|
|
137
|
+
stream.write("\n")
|
|
138
|
+
|
|
139
|
+
@contextmanager
|
|
140
|
+
def step(self, name: str) -> Iterator[None]:
|
|
141
|
+
self._current_step = name
|
|
142
|
+
self._record(name, "started")
|
|
143
|
+
yield
|
|
144
|
+
self._record(name, "completed")
|
|
145
|
+
|
|
146
|
+
def prepare(self, staged: Path, changes: tuple[tuple[str, Path, bytes, bytes], ...]) -> None:
|
|
147
|
+
modules = self.destination.parent
|
|
148
|
+
if modules.is_symlink() or modules.is_junction():
|
|
149
|
+
raise CommandError(f"Refusing linked modules directory: {modules}")
|
|
150
|
+
marker = modules / "__init__.py"
|
|
151
|
+
marker_original = _content(marker)
|
|
152
|
+
marker_change = (
|
|
153
|
+
(("package_marker", marker, marker_original, marker_original or b""),)
|
|
154
|
+
if all(path != marker for _, path, _, _ in changes)
|
|
155
|
+
else ()
|
|
156
|
+
)
|
|
157
|
+
self._changes = tuple(
|
|
158
|
+
_FileChange(
|
|
159
|
+
name,
|
|
160
|
+
path,
|
|
161
|
+
original,
|
|
162
|
+
proposed,
|
|
163
|
+
path.with_name(f".pddd-{self.operation_id}-{name}"),
|
|
164
|
+
)
|
|
165
|
+
for name, path, original, proposed in (
|
|
166
|
+
*marker_change,
|
|
167
|
+
*changes,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
self._module_files = _tree(staged)
|
|
171
|
+
originals = self.recovery / "original"
|
|
172
|
+
proposed = self.recovery / "proposed"
|
|
173
|
+
originals.mkdir()
|
|
174
|
+
proposed.mkdir()
|
|
175
|
+
files: list[dict[str, object]] = []
|
|
176
|
+
for change in self._changes:
|
|
177
|
+
original = originals / change.name
|
|
178
|
+
generated = proposed / change.name
|
|
179
|
+
if change.original is not None:
|
|
180
|
+
original.write_bytes(change.original)
|
|
181
|
+
generated.write_bytes(change.proposed)
|
|
182
|
+
files.append(
|
|
183
|
+
{
|
|
184
|
+
"step": change.name,
|
|
185
|
+
"target": str(change.target),
|
|
186
|
+
"pending": str(change.pending),
|
|
187
|
+
"original": str(original) if change.original is not None else None,
|
|
188
|
+
"original_sha256": (
|
|
189
|
+
_digest(change.original) if change.original is not None else None
|
|
190
|
+
),
|
|
191
|
+
"proposed": str(generated),
|
|
192
|
+
"proposed_sha256": _digest(change.proposed),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
self._write_json(
|
|
196
|
+
"plan.json",
|
|
197
|
+
{
|
|
198
|
+
"module_source": str(staged),
|
|
199
|
+
"module_target": str(self.destination),
|
|
200
|
+
"module_pending": str(self.pending),
|
|
201
|
+
"module_files": self._module_files,
|
|
202
|
+
"modules_directory": str(modules),
|
|
203
|
+
"modules_directory_existed": modules.exists(),
|
|
204
|
+
"files": files,
|
|
205
|
+
"publish_order": ["module", *(change.name for change in self._changes)],
|
|
206
|
+
"sync_command": ["uv", "sync", "--directory", str(self.root)],
|
|
207
|
+
# 保存完整策略,重试前才出现的覆盖也必须清理;不保存环境变量值。
|
|
208
|
+
"sync_environment_unset": _PROJECT_ENVIRONMENT_OVERRIDES,
|
|
209
|
+
"sync_log": str(self.recovery / "uv-sync.log"),
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
self._check_originals()
|
|
213
|
+
|
|
214
|
+
def _check_originals(self) -> None:
|
|
215
|
+
modules = self.destination.parent
|
|
216
|
+
if modules.is_symlink() or modules.is_junction():
|
|
217
|
+
raise CommandError(f"Modules directory changed to a link: {modules}")
|
|
218
|
+
if self.destination.exists() or self.destination.is_symlink():
|
|
219
|
+
raise CommandError(f"Module destination already exists: {self.destination}")
|
|
220
|
+
for change in self._changes:
|
|
221
|
+
if _content(change.target) != change.original:
|
|
222
|
+
raise CommandError(f"Content changed during generation: {change.target}")
|
|
223
|
+
|
|
224
|
+
def publish(self, staged: Path) -> None:
|
|
225
|
+
with self.step("local_copy"):
|
|
226
|
+
self._check_originals()
|
|
227
|
+
self.destination.parent.mkdir(exist_ok=True)
|
|
228
|
+
shutil.copytree(staged, self.pending)
|
|
229
|
+
for change in self._changes:
|
|
230
|
+
# 包标记用独占创建;原有文件的待发布副本与目标处于同一文件系统。
|
|
231
|
+
if change.name == "package_marker":
|
|
232
|
+
continue
|
|
233
|
+
with change.pending.open("xb") as stream:
|
|
234
|
+
stream.write(change.proposed)
|
|
235
|
+
shutil.copymode(change.target, change.pending)
|
|
236
|
+
if _tree(self.pending) != self._module_files:
|
|
237
|
+
raise CommandError(f"Incomplete module copy: {self.pending}")
|
|
238
|
+
with self.step("module"):
|
|
239
|
+
self._check_originals()
|
|
240
|
+
self.pending.rename(self.destination)
|
|
241
|
+
for change in self._changes:
|
|
242
|
+
with self.step(change.name):
|
|
243
|
+
if _content(change.target) != change.original:
|
|
244
|
+
raise CommandError(f"Content changed during generation: {change.target}")
|
|
245
|
+
if change.name == "package_marker":
|
|
246
|
+
if change.original is None:
|
|
247
|
+
with change.target.open("xb") as stream:
|
|
248
|
+
stream.write(change.proposed)
|
|
249
|
+
else:
|
|
250
|
+
if _content(change.pending) != change.proposed:
|
|
251
|
+
raise CommandError(f"Pending content changed: {change.pending}")
|
|
252
|
+
change.pending.replace(change.target)
|
|
253
|
+
with self.step("files_published"):
|
|
254
|
+
if self.file_states() != dict.fromkeys(
|
|
255
|
+
("module", *(change.name for change in self._changes)), "published"
|
|
256
|
+
):
|
|
257
|
+
raise CommandError("Published files no longer match the generation plan")
|
|
258
|
+
|
|
259
|
+
def sync(self) -> None:
|
|
260
|
+
command = ["uv", "sync", "--directory", str(self.root)]
|
|
261
|
+
self._current_step = "sync"
|
|
262
|
+
self._record("sync", "started")
|
|
263
|
+
with self.recovery.joinpath("uv-sync.log").open("xb") as log:
|
|
264
|
+
result = subprocess.run(
|
|
265
|
+
command,
|
|
266
|
+
cwd=self.root,
|
|
267
|
+
env=project_environment(),
|
|
268
|
+
stdout=log,
|
|
269
|
+
stderr=subprocess.STDOUT,
|
|
270
|
+
check=False,
|
|
271
|
+
)
|
|
272
|
+
self._record(
|
|
273
|
+
"sync",
|
|
274
|
+
"completed" if result.returncode == 0 else "failed",
|
|
275
|
+
returncode=result.returncode,
|
|
276
|
+
)
|
|
277
|
+
result.check_returncode()
|
|
278
|
+
|
|
279
|
+
def file_states(self) -> dict[str, str]:
|
|
280
|
+
"""日志可能停在写后记录之前,诊断必须重新读取磁盘内容。"""
|
|
281
|
+
if not self._changes:
|
|
282
|
+
return {"publication": "not_started"}
|
|
283
|
+
states: dict[str, str] = {}
|
|
284
|
+
try:
|
|
285
|
+
self.destination.lstat()
|
|
286
|
+
except FileNotFoundError:
|
|
287
|
+
states["module"] = "not_published"
|
|
288
|
+
except OSError:
|
|
289
|
+
states["module"] = "unknown/conflict"
|
|
290
|
+
else:
|
|
291
|
+
try:
|
|
292
|
+
states["module"] = (
|
|
293
|
+
"published"
|
|
294
|
+
if _tree(self.destination) == self._module_files
|
|
295
|
+
else "unknown/conflict"
|
|
296
|
+
)
|
|
297
|
+
except (OSError, CommandError):
|
|
298
|
+
states["module"] = "unknown/conflict"
|
|
299
|
+
for change in self._changes:
|
|
300
|
+
try:
|
|
301
|
+
content = _content(change.target)
|
|
302
|
+
states[change.name] = (
|
|
303
|
+
"published"
|
|
304
|
+
if content == change.proposed
|
|
305
|
+
else "not_published"
|
|
306
|
+
if content == change.original
|
|
307
|
+
else "unknown/conflict"
|
|
308
|
+
)
|
|
309
|
+
except (OSError, CommandError):
|
|
310
|
+
states[change.name] = "unknown/conflict"
|
|
311
|
+
return states
|
|
312
|
+
|
|
313
|
+
def _report_failure(self, error: BaseException) -> None:
|
|
314
|
+
try:
|
|
315
|
+
self._record(
|
|
316
|
+
self._current_step,
|
|
317
|
+
"interrupted" if isinstance(error, KeyboardInterrupt) else "failed",
|
|
318
|
+
)
|
|
319
|
+
except OSError as record_error:
|
|
320
|
+
print(
|
|
321
|
+
f"Could not record failure ({type(record_error).__name__}); inspect disk contents.",
|
|
322
|
+
file=sys.stderr,
|
|
323
|
+
)
|
|
324
|
+
print(
|
|
325
|
+
f"Module generation stopped at {self._current_step}; "
|
|
326
|
+
f"disk state: {self.file_states()}.\n"
|
|
327
|
+
f"All remaining files are retained. Review {self.recovery / 'RECOVERY.md'}; "
|
|
328
|
+
f"rendering: {self.rendering}.\n"
|
|
329
|
+
"After confirming all published contents, use the isolated uv sync retry "
|
|
330
|
+
"in RECOVERY.md.",
|
|
331
|
+
file=sys.stderr,
|
|
332
|
+
flush=True,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
_RECOVERY = """# Manual module recovery
|
|
337
|
+
|
|
338
|
+
Keep this directory and every path recorded in operation.json / plan.json.
|
|
339
|
+
No rollback or cleanup was performed. Stop other generators, editors and uv processes
|
|
340
|
+
before inspecting or changing the project. The generator lock does not lock editors.
|
|
341
|
+
|
|
342
|
+
## Inspect first
|
|
343
|
+
|
|
344
|
+
operation.json identifies the backend, CLI/template version and rendering location.
|
|
345
|
+
plan.json records exact original/proposed copies, SHA-256 digests, module directory
|
|
346
|
+
entries, pending paths, prior directory/marker existence and publication order.
|
|
347
|
+
It also records the sync command and the environment overrides to clear for that command.
|
|
348
|
+
steps.jsonl records intent before each action and completion afterwards; a missing or
|
|
349
|
+
partial final line does not prove the action failed. uv-sync.log contains install output.
|
|
350
|
+
A complete plan is required before publication. If plan.json is missing or unreadable,
|
|
351
|
+
stop recovery and inspect retained copies and actual targets; do not infer that nothing
|
|
352
|
+
was written. Retain any partial preparation files.
|
|
353
|
+
|
|
354
|
+
Save this read-only inspection outside the project, then run `python -I <inspection-file>`
|
|
355
|
+
with Python 3.12 or newer:
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
import hashlib, json
|
|
359
|
+
from pathlib import Path
|
|
360
|
+
recovery = Path(<RECOVERY_PATH>)
|
|
361
|
+
plan = json.loads((recovery / "plan.json").read_text(encoding="utf-8"))
|
|
362
|
+
for item in plan["files"]:
|
|
363
|
+
path = Path(item["target"])
|
|
364
|
+
linked = path.is_symlink() or path.is_junction()
|
|
365
|
+
actual = None
|
|
366
|
+
if path.is_file() and not linked:
|
|
367
|
+
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
368
|
+
state = "UNKNOWN - inspect before editing"
|
|
369
|
+
if not linked and (path.is_file() or not path.exists()):
|
|
370
|
+
if actual == item["proposed_sha256"]:
|
|
371
|
+
state = "published"
|
|
372
|
+
elif actual == item["original_sha256"]:
|
|
373
|
+
state = "original"
|
|
374
|
+
print(item["step"], path, state, "actual:", actual)
|
|
375
|
+
module = Path(plan["module_target"])
|
|
376
|
+
actual = {}
|
|
377
|
+
directories = [module] if module.is_dir() else []
|
|
378
|
+
while directories:
|
|
379
|
+
for path in directories.pop().iterdir():
|
|
380
|
+
if path.is_symlink() or path.is_junction():
|
|
381
|
+
value = "UNKNOWN LINK"
|
|
382
|
+
elif path.is_dir():
|
|
383
|
+
value = "directory"
|
|
384
|
+
directories.append(path)
|
|
385
|
+
else:
|
|
386
|
+
value = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
387
|
+
actual[path.relative_to(module).as_posix()] = value
|
|
388
|
+
print("module missing:", not module.exists())
|
|
389
|
+
print("module root is linked:", module.is_symlink() or module.is_junction())
|
|
390
|
+
for key in actual.keys() | plan["module_files"].keys():
|
|
391
|
+
if actual.get(key) != plan["module_files"].get(key):
|
|
392
|
+
print("module difference:", key)
|
|
393
|
+
print("Inspect all links and unknown files manually; never overwrite them.")
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Choose and perform each step manually
|
|
397
|
+
|
|
398
|
+
1. Compare the complete module tree (including extra files, directories and links)
|
|
399
|
+
with module_files and module_source. A matching target is already published.
|
|
400
|
+
If absent, review the complete staged copy before manually copying to that absent
|
|
401
|
+
target. Stop on partial content, any link, extra file or differing digest.
|
|
402
|
+
2. Inspect files in publish_order: package_marker, startup, metadata. An unchanged
|
|
403
|
+
original marker is retained. Create a missing marker only when recorded as absent.
|
|
404
|
+
For startup / metadata, continue only while target equals original_sha256; use
|
|
405
|
+
the proposed copy named in plan.json. For a manual rollback, restore the original
|
|
406
|
+
copy only while target equals proposed_sha256. Verify the copy's digest as well.
|
|
407
|
+
Prepare a complete sibling file, recheck the target, then replace that one file.
|
|
408
|
+
Any different content is a conflict: review the diff and decide what to keep.
|
|
409
|
+
3. Leave generated modules, markers, pending files and recovery directories in place.
|
|
410
|
+
Removing or moving any path is a separate explicit decision. Do not rerun add module
|
|
411
|
+
to repair a partially published operation or overwrite an existing destination.
|
|
412
|
+
4. Once every target matches the proposed contents, use the isolated sync retry below.
|
|
413
|
+
A missing success record means installation is unconfirmed. uv may already have
|
|
414
|
+
changed uv.lock or the environment; neither is automatically rolled back.
|
|
415
|
+
|
|
416
|
+
Single-file replacement is not a transaction across files. External edits can race
|
|
417
|
+
with checks; this record does not promise power-loss durability or automatic recovery.
|
|
418
|
+
|
|
419
|
+
## Retry installation independently
|
|
420
|
+
|
|
421
|
+
Save this script outside the project, then run `python -I <retry-file>` with Python 3.12
|
|
422
|
+
or newer. It runs only uv sync in the recorded backend. The -I option isolates Python
|
|
423
|
+
from external Python settings; the script clears the same overrides as the CLI from
|
|
424
|
+
the child environment, including overrides added after generation. The current shell
|
|
425
|
+
environment is preserved. No framework import or working backend environment is needed.
|
|
426
|
+
Check the process exit status and output; the original operation records remain unchanged.
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
import json, os, subprocess
|
|
430
|
+
from pathlib import Path
|
|
431
|
+
recovery = Path(<RECOVERY_PATH>)
|
|
432
|
+
operation = json.loads((recovery / "operation.json").read_text(encoding="utf-8"))
|
|
433
|
+
plan = json.loads((recovery / "plan.json").read_text(encoding="utf-8"))
|
|
434
|
+
environment = dict(os.environ)
|
|
435
|
+
for key in plan["sync_environment_unset"]:
|
|
436
|
+
environment.pop(key, None)
|
|
437
|
+
result = subprocess.run(
|
|
438
|
+
plan["sync_command"], cwd=operation["project_root"], env=environment, check=False
|
|
439
|
+
)
|
|
440
|
+
raise SystemExit(result.returncode)
|
|
441
|
+
```
|
|
442
|
+
"""
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"module_name": "
|
|
1
|
+
{"module_name": "capability", "class_prefix": "Capability"}
|
|
@@ -1,63 +1,53 @@
|
|
|
1
1
|
# {{ cookiecutter.class_prefix }} module
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
This is a DDD module skeleton. It has no business rules, entities, DTOs, services, endpoints, database tables, seed data, background tasks, or test cases. The Host selects its Application, SQLAlchemy, and HttpApi Modules; infrastructure providers remain Host-owned.
|
|
4
4
|
|
|
5
|
-
[Project setup](../../../../README.md) · [Working rules](../../../../AGENTS.md) · [Architecture](../../../../docs/architecture.md) · [
|
|
5
|
+
[Project setup](../../../../README.md) · [Working rules](../../../../AGENTS.md) · [Architecture](../../../../docs/architecture.md) · [Design checks](../../../../docs/development.md#design-and-delivery-checks) · [Directory rules](../../../../docs/development.md#module-layout-and-coding-rules)
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Record the business responsibility
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Before implementation, record the confirmed capability, caller, business language, public contracts, and data owner here. For each invariant, identify its aggregate or service owner, business operation, and a concrete rejection example. Record consistency, concurrency, idempotency, failure-after-commit, and collaboration decisions when relevant. Unresolved questions remain explicit; directory names and documentation recipes do not authorize business rules.
|
|
10
|
+
|
|
11
|
+
Keep this README current with the implementation and its actual verification. A small correction may reference existing conclusions rather than creating another design document.
|
|
12
|
+
|
|
13
|
+
## Layers and initial wiring
|
|
14
|
+
|
|
15
|
+
| Layer | Responsibility and current state |
|
|
10
16
|
| --- | --- |
|
|
11
|
-
| Shared
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
|
15
|
-
|
|
|
16
|
-
|
|
|
17
|
-
| Background and lifecycle | [Approval Job](application/background_jobs/order_approval/handler.py), [statistics schedule](application/background_jobs/order_statistics/schedule.py), [maintenance Worker](application/background_workers/order_maintenance_worker.py), [HostedService](application/hosted_services/order_integration_service.py) |
|
|
18
|
-
| Events, interception, settings refresh | [After-commit handler](application/event_handlers/order_changed_handler.py), [interceptor](application/interceptors/order_timing_interceptor.py), [observer](application/setting_handlers/approval_setting_observer.py) |
|
|
19
|
-
| Persistence | [Model](sqlalchemy/models/order_model.py), [repository](sqlalchemy/repositories/order_repository.py), [registration](sqlalchemy/module.py) |
|
|
20
|
-
| Transport | [HTTP exposure](http_api/module.py), [file router](http_api/routers/order_files.py), [WebSocket](http_api/websockets/order_socket.py) |
|
|
21
|
-
|
|
22
|
-
## Behavior and enablement
|
|
23
|
-
|
|
24
|
-
- Query, management, and approval use separate `{{ cookiecutter.class_prefix }}QueryApplicationService`, `{{ cookiecutter.class_prefix }}ManagementApplicationService`, and `{{ cookiecutter.class_prefix }}ApprovalApplicationService` contracts. The exposure decorator contributes the ContractsModule dependency; overrides retain the sample's deliberate paths and operation IDs. Other conventional methods are automatic, with plain GET Pydantic DTOs bound as query parameters unless explicitly declared otherwise. Reporting uses `OrderReportingService` internally without end-user permissions or HTTP exposure.
|
|
25
|
-
- The aggregate owns pending-to-approved transitions. The domain service reads the approval setting; the caller owns version checks, transaction, and save. Repository writes collect events; AFTER_COMMIT removes the cache before online notification. Notification failure cannot undo a committed write.
|
|
26
|
-
- The approval Job participates in transactional enqueue and ignores missing/already-approved orders. The statistics schedule refers to its Job type and typed payload; the Job definition owns persisted name/version/schema. Generated Workers are code-disabled; HostedService and periodic schedule require explicit Module registration as described in the project development guide. Settings refresh and timing interception reuse framework extensions.
|
|
27
|
-
- The hosted example uses `stopping` for an independent business call and an SDK thread callback with the temporary context, then `stop` joins the actual thread after accepted work drains. Startup contexts stay closed during shutdown. To keep message input during this phase, declare this service in the channel's `stopping_owners`, depend on its Module owner, and pass `stopping=context`; no channel opts in by default.
|
|
28
|
-
- File upload/download/stream and `/ws/{{ cookiecutter.module_name }}` call public contracts. Typed messages in [order_messages](domain_shared/messages/order_messages.py) own their versioned names and payload schemas; handlers and the socket explicitly map business values. Sending reports local queue acceptance, with no offline replay, cross-process backplane or client acknowledgment.
|
|
29
|
-
|
|
30
|
-
Startup [Options](application/options/order_options.py) bind to the `{{ cookiecutter.module_name }}` YAML section; `allow_background_approval` defaults to true. Runtime approval settings and seed contributors belong to Domain. Its Module declares `SettingsModule` and `DataSeedingModule`; contributors are independent DI services, not ApplicationServices. The Domain [OrderApprovalService](domain/services/order_approval_service.py) opts into `ValidationEnabled` through the transitive InvocationModule dependency, retains its transient lifetime, and leaves saving and transaction completion to its caller. Capability dependencies and typed declarations are owned by each Module, while Host selects providers. Change declarations at their owners rather than copying names into another registry.
|
|
31
|
-
|
|
32
|
-
## Optional in-process observations
|
|
33
|
-
|
|
34
|
-
The external integration example includes a pure-value [snapshot](domain_shared/messages/order_observation.py)
|
|
35
|
-
and a [Handler](application/hosted_services/order_observation_handler.py). It is not enabled by
|
|
36
|
-
default and does not replace existing callbacks. Follow the project's development guide to add
|
|
37
|
-
MessagingModule, declare the typed channel and register the ACTION Handler. Only pending
|
|
38
|
-
snapshots may coalesce; acceptance, completion, commit and external acknowledgement differ.
|
|
17
|
+
| `domain_shared` | Shared contract values, constants, errors, permissions, and messages. No business declarations or framework capability dependencies yet. |
|
|
18
|
+
| `domain` | Aggregates, domain services, repository contracts, events, settings, and seed contributors. Depends on DomainShared; it contains no business implementation. |
|
|
19
|
+
| `application_contracts` | Public service Protocols and input/output DTOs. Depends on DomainShared; contracts do not depend on Domain or implementations. |
|
|
20
|
+
| `application` | Use-case orchestration and handlers. Depends on Domain and ApplicationContracts; its package is scanned but has no services or task declarations. |
|
|
21
|
+
| `sqlalchemy` | Persistence implementations and owned migrations. Depends on Domain and SqlAlchemyPersistenceModule; it retains its Base, model discovery, repository scanning, and migration registration. |
|
|
22
|
+
| `http_api` | Transport declarations. Depends on FastApiModule and exposes this module's contracts; empty contracts produce no business endpoints. |
|
|
39
23
|
|
|
40
|
-
|
|
24
|
+
All responsibility directories and package markers are retained for navigation. Add implementations only for authorized requirements; do not fill them with placeholder interfaces or services. `module.py` owns dependencies, registration, and the seven lifecycle hooks. Empty hooks use `pass`; package markers contain no business or compatibility exports. `tests/` initially contains only its package marker.
|
|
25
|
+
|
|
26
|
+
## Implement the first use case
|
|
41
27
|
|
|
42
|
-
|
|
28
|
+
1. Identify **owner -> layer -> directory -> verified framework API**, following the project design checks. Add shared values only when they are actually shared.
|
|
29
|
+
2. Model aggregate transitions and invariants in Domain. Define domain-facing repositories without ORM or Session types. Add public DTOs and service Protocols in ApplicationContracts, then implement the authorized use case in Application.
|
|
30
|
+
3. Declare the framework capability dependencies where used: for example, ApplicationServicesModule for application services, AuthorizationModule for permission definitions, or DataSeedingModule for seed contributors. The Host's provider choices do not repair missing module dependencies.
|
|
31
|
+
4. Implement persistence under `sqlalchemy/models` and `sqlalchemy/repositories`. The existing [Base](sqlalchemy/models/base.py) and [registration](sqlalchemy/module.py) own this module's metadata and migration branch. Business models, tables, and schema decisions must come from requirements.
|
|
32
|
+
5. Conventional HTTP exposure can discover the new public service contracts. Add manual routers only where the transport requires them; keep use-case rules out of HTTP. Register workers, jobs, hosted services, schedules, or event handlers explicitly when required.
|
|
33
|
+
6. Update this responsibility record and run the smallest relevant existing checks. New tests and infrastructure validation require the task's authorization; neither an empty test directory nor successful composition proves business correctness.
|
|
43
34
|
|
|
44
|
-
|
|
35
|
+
## Persistence and verification
|
|
36
|
+
|
|
37
|
+
An empty skeleton retains model and migration registration but has no business tables or revisions. Migration commands still use the configured database; an empty registered Base is different from a module with no model registration. Add actual models before generating a business revision, and review it before applying:
|
|
45
38
|
|
|
46
39
|
```sh
|
|
47
40
|
pddd db revision --module {{ cookiecutter.module_name }}
|
|
48
41
|
pddd db upgrade --module {{ cookiecutter.module_name }}
|
|
49
42
|
pddd db status --module {{ cookiecutter.module_name }}
|
|
50
|
-
pddd db seed --module {{ cookiecutter.module_name }}
|
|
51
|
-
cd backend
|
|
52
|
-
uv run pytest src/modules/{{ cookiecutter.module_name }}/tests
|
|
53
43
|
```
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
## Data and events
|
|
45
|
+
Run `pddd db seed --module {{ cookiecutter.module_name }}` only after declaring the intended contributors and preparing their database. Keep applied revisions immutable and upgrade prerequisites explicitly. Host startup never migrates or seeds.
|
|
58
46
|
|
|
59
|
-
|
|
47
|
+
Run installed `pddd` commands from any application directory. Run native tools from `backend/`:
|
|
60
48
|
|
|
61
|
-
|
|
49
|
+
```sh
|
|
50
|
+
uv build --no-sources
|
|
51
|
+
```
|
|
62
52
|
|
|
63
|
-
|
|
53
|
+
After authorized test cases exist, run `uv run pytest src/modules/{{ cookiecutter.module_name }}/tests`. The empty skeleton has no tests to pass. Host tests cover generic health and identity and require their configured infrastructure; see the project verification guide. Module tests are excluded from production wheels and service discovery. The `basic` template remains available for an ordinary in-process service without DDD layers.
|