python-ddd-framework 0.4.0__py3-none-any.whl → 0.6.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/runtime.py +46 -19
- 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 -500
- python_ddd_framework/application_services/module.py +16 -5
- python_ddd_framework/authorization/module.py +1 -1
- python_ddd_framework/background_execution/local.py +20 -1
- python_ddd_framework/background_execution/processes.py +11 -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 +11 -2
- 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 +46 -36
- python_ddd_framework/developer_kit/publication.py +442 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
- 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/action.py +2 -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/realtime/runtime.py +1 -1
- python_ddd_framework/fastapi/request_context.py +2 -5
- python_ddd_framework/fastapi/routing.py +4 -10
- python_ddd_framework/fastapi/service_endpoints.py +182 -0
- python_ddd_framework/fastapi/service_routes.py +237 -0
- python_ddd_framework/fastapi/transfer.py +1 -1
- python_ddd_framework/hosted_services/bridge.py +76 -21
- python_ddd_framework/hosted_services/contracts.py +5 -1
- python_ddd_framework/hosted_services/runtime.py +71 -17
- 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 +1 -1
- 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 +91 -0
- python_ddd_framework/lifecycle/participants.py +4 -0
- python_ddd_framework/messaging/channel.py +35 -8
- python_ddd_framework/messaging/contracts.py +9 -0
- python_ddd_framework/messaging/module.py +13 -0
- python_ddd_framework/messaging/runtime.py +17 -2
- python_ddd_framework/modularity/discovery.py +2 -7
- python_ddd_framework/modularity/graph.py +2 -8
- 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 +4 -7
- python_ddd_framework/unit_of_work/module.py +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
|
@@ -10,10 +10,10 @@ from uuid import uuid4
|
|
|
10
10
|
from dishka import AsyncContainer, Scope
|
|
11
11
|
|
|
12
12
|
from ..application_services.errors import ApplicationInvocationRejectedError
|
|
13
|
-
from ..application_services.invocation import _InvocationRuntime
|
|
14
13
|
from ..authorization import CurrentUser
|
|
15
14
|
from ..background_execution.lifecycle import _TaskExecution
|
|
16
15
|
from ..invocation import BackgroundSystemCaller
|
|
16
|
+
from ..invocation.runtime import _InvocationRuntime
|
|
17
17
|
from ..unit_of_work.options import _DEFAULT_CONNECTION_NAME
|
|
18
18
|
from .contracts import BackgroundWorkerContext, BackgroundWorkerDefinition
|
|
19
19
|
|
|
@@ -9,7 +9,6 @@ from collections.abc import Mapping
|
|
|
9
9
|
from dishka import AsyncContainer
|
|
10
10
|
|
|
11
11
|
from ..application.build_spec import _ApplicationBuildSpec
|
|
12
|
-
from ..application_services.invocation import _InvocationRuntime
|
|
13
12
|
from ..background_execution import (
|
|
14
13
|
BackgroundExecutionMode,
|
|
15
14
|
BackgroundJobOptions,
|
|
@@ -27,6 +26,7 @@ from ..background_execution.processes import _ProcessSlot
|
|
|
27
26
|
from ..background_jobs import BackgroundJobCatalog
|
|
28
27
|
from ..errors.lifecycle import ApplicationRuntimeError
|
|
29
28
|
from ..invocation import BackgroundSystemCaller
|
|
29
|
+
from ..invocation.runtime import _InvocationRuntime
|
|
30
30
|
from ..lifecycle import RuntimeParticipant, ShutdownReason
|
|
31
31
|
from .catalog import BackgroundWorkerCatalog
|
|
32
32
|
from .contracts import BackgroundExecutionOptions, BackgroundExecutionProfile
|
|
@@ -46,7 +46,6 @@ class _BackgroundCoordinator:
|
|
|
46
46
|
raise _BackgroundExecutionStartFailure(original_error=error) from error
|
|
47
47
|
|
|
48
48
|
async def stop(reason: ShutdownReason) -> tuple[BaseException, ...]:
|
|
49
|
-
await self.stop()
|
|
50
49
|
return ()
|
|
51
50
|
|
|
52
51
|
async def wait() -> ApplicationRuntimeError:
|
|
@@ -62,6 +61,8 @@ class _BackgroundCoordinator:
|
|
|
62
61
|
prepare=prepare,
|
|
63
62
|
start=start,
|
|
64
63
|
stop=stop,
|
|
64
|
+
quiesce=self.quiesce,
|
|
65
|
+
drain=self.stop,
|
|
65
66
|
failure=lambda: wait() if self.has_runtime_failure_source else None,
|
|
66
67
|
)
|
|
67
68
|
|
|
@@ -242,6 +243,14 @@ class _BackgroundCoordinator:
|
|
|
242
243
|
return slot.request(key, start)
|
|
243
244
|
return self._tasks[key].request(start)
|
|
244
245
|
|
|
246
|
+
def quiesce(self) -> None:
|
|
247
|
+
self._started = False
|
|
248
|
+
for key, task in self._tasks.items():
|
|
249
|
+
if key in self._local:
|
|
250
|
+
task.quiesce()
|
|
251
|
+
for slot in self._slots.values():
|
|
252
|
+
slot.quiesce()
|
|
253
|
+
|
|
245
254
|
async def stop(self) -> None:
|
|
246
255
|
# 即使启动前的预占或 Module 初始化失败,也必须释放已取得的位置。
|
|
247
256
|
operations = [task.shutdown() for key, task in self._tasks.items() if key in self._local]
|
|
@@ -21,6 +21,7 @@ class _CachePending:
|
|
|
21
21
|
async def after_commit(self) -> None:
|
|
22
22
|
failures: list[Exception] = []
|
|
23
23
|
try:
|
|
24
|
+
# 普通错误继续后项,取消立即传播;逐项 await 保留尚未发送命令的边界。
|
|
24
25
|
for _, apply in self.writes.values():
|
|
25
26
|
try:
|
|
26
27
|
await apply()
|
|
@@ -40,7 +41,4 @@ class _CacheLifecycleFactory:
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def _pending(work: UnitOfWork) -> _CachePending:
|
|
43
|
-
|
|
44
|
-
if isinstance(lifecycle, _CachePending):
|
|
45
|
-
return lifecycle
|
|
46
|
-
raise RuntimeError("Caching lifecycle is not configured for this UnitOfWork")
|
|
44
|
+
return work._lifecycle(_CachePending)
|
|
@@ -18,6 +18,16 @@ from .errors import CommandError
|
|
|
18
18
|
|
|
19
19
|
HOST_ENTRY_POINT_GROUP = "python_ddd_framework.hosts"
|
|
20
20
|
MODULE_ENTRY_POINT_GROUP = "python_ddd_framework.modules"
|
|
21
|
+
_PROJECT_ENVIRONMENT_OVERRIDES = (
|
|
22
|
+
"VIRTUAL_ENV",
|
|
23
|
+
"PYTHONPATH",
|
|
24
|
+
"PYTHONHOME",
|
|
25
|
+
"UV_PROJECT_ENVIRONMENT",
|
|
26
|
+
"UV_PROJECT",
|
|
27
|
+
"UV_WORKING_DIR",
|
|
28
|
+
"UV_WORKING_DIRECTORY",
|
|
29
|
+
"UV_PYTHON",
|
|
30
|
+
)
|
|
21
31
|
|
|
22
32
|
|
|
23
33
|
def read_project(path: Path) -> dict[str, Any]:
|
|
@@ -117,15 +127,6 @@ class Project:
|
|
|
117
127
|
def project_environment() -> dict[str, str]:
|
|
118
128
|
# 父进程可能来自 ROS、uv tool 或另一个项目;后端环境由本项目声明和 uv 选择。
|
|
119
129
|
environment = dict(os.environ)
|
|
120
|
-
for key in
|
|
121
|
-
"VIRTUAL_ENV",
|
|
122
|
-
"PYTHONPATH",
|
|
123
|
-
"PYTHONHOME",
|
|
124
|
-
"UV_PROJECT_ENVIRONMENT",
|
|
125
|
-
"UV_PROJECT",
|
|
126
|
-
"UV_WORKING_DIR",
|
|
127
|
-
"UV_WORKING_DIRECTORY",
|
|
128
|
-
"UV_PYTHON",
|
|
129
|
-
):
|
|
130
|
+
for key in _PROJECT_ENVIRONMENT_OVERRIDES:
|
|
130
131
|
environment.pop(key, None)
|
|
131
132
|
return environment
|
|
@@ -26,6 +26,7 @@ from ..cli import ENVIRONMENT_VARIABLE
|
|
|
26
26
|
from ..cli.errors import CommandError
|
|
27
27
|
from ..cli.project import Project, project_environment, read_project
|
|
28
28
|
from .project_metadata import add_module_metadata
|
|
29
|
+
from .publication import _ModulePublication
|
|
29
30
|
from .source import framework_source
|
|
30
31
|
from .wiring import add_dependency
|
|
31
32
|
|
|
@@ -41,8 +42,11 @@ def _name(value: str) -> tuple[str, str]:
|
|
|
41
42
|
return package, "".join(part.capitalize() for part in package.split("_"))
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
def _render(template: str, context: dict[str, Any]) -> Path:
|
|
45
|
-
staging
|
|
45
|
+
def _render(template: str, context: dict[str, Any], *, staging: Path | None = None) -> Path:
|
|
46
|
+
if staging is None:
|
|
47
|
+
staging = Path(tempfile.mkdtemp(prefix="python-ddd-framework-generation-"))
|
|
48
|
+
else:
|
|
49
|
+
staging.mkdir()
|
|
46
50
|
root = resources.files(__package__).joinpath("templates").joinpath(template)
|
|
47
51
|
with resources.as_file(root) as template_path:
|
|
48
52
|
# Cookiecutter 用相对路径加载;安装目录较深时先复制到短 staging,避免 Windows MAX_PATH。
|
|
@@ -152,6 +156,26 @@ def new_project(
|
|
|
152
156
|
def add_module(name: str, dry_run: bool, *, template: Literal["basic", "ddd"] = "ddd") -> None:
|
|
153
157
|
package, class_prefix = _name(name)
|
|
154
158
|
project = Project.current()
|
|
159
|
+
if dry_run:
|
|
160
|
+
_add_module(project, package, class_prefix, template, None)
|
|
161
|
+
else:
|
|
162
|
+
with _ModulePublication(project.root, package, template) as publication:
|
|
163
|
+
_add_module(project, package, class_prefix, template, publication)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _add_module(
|
|
167
|
+
project: Project,
|
|
168
|
+
package: str,
|
|
169
|
+
class_prefix: str,
|
|
170
|
+
template: Literal["basic", "ddd"],
|
|
171
|
+
publication: _ModulePublication | None,
|
|
172
|
+
) -> None:
|
|
173
|
+
destination = project.root / "src" / "modules" / package
|
|
174
|
+
if destination.exists() or destination.is_symlink():
|
|
175
|
+
raise CommandError(
|
|
176
|
+
f"Module destination already exists: {destination}; review retained operations in "
|
|
177
|
+
f"{project.root / '.python-ddd-framework' / 'generation'}"
|
|
178
|
+
)
|
|
155
179
|
application = project.host("application").load()(environment="development")
|
|
156
180
|
if not isinstance(application, Application):
|
|
157
181
|
raise CommandError("The application entry point must return Application")
|
|
@@ -166,11 +190,8 @@ def add_module(name: str, dry_run: bool, *, template: Literal["basic", "ddd"] =
|
|
|
166
190
|
raise CommandError(
|
|
167
191
|
"Startup Module source must belong to the current project's src directory"
|
|
168
192
|
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if destination.exists():
|
|
172
|
-
raise CommandError(f"Module destination already exists: {destination}")
|
|
173
|
-
source = source_path.read_text(encoding="utf-8")
|
|
193
|
+
source_bytes = source_path.read_bytes()
|
|
194
|
+
source = source_bytes.decode("utf-8")
|
|
174
195
|
updated = source
|
|
175
196
|
entries = (
|
|
176
197
|
((f"modules.{package}.module", class_prefix + "Module"),)
|
|
@@ -187,18 +208,19 @@ def add_module(name: str, dry_run: bool, *, template: Literal["basic", "ddd"] =
|
|
|
187
208
|
for import_path, class_name in entries:
|
|
188
209
|
updated = add_dependency(updated, startup.__name__, import_path, class_name)
|
|
189
210
|
metadata_path = project.root / "pyproject.toml"
|
|
190
|
-
|
|
211
|
+
metadata_bytes = metadata_path.read_bytes()
|
|
212
|
+
metadata_source = metadata_bytes.decode("utf-8")
|
|
191
213
|
alias_path, alias_class = entries[0 if template == "basic" else 1]
|
|
192
214
|
updated_metadata = add_module_metadata(metadata_source, package, f"{alias_path}:{alias_class}")
|
|
193
215
|
ast.parse(updated)
|
|
194
|
-
if
|
|
216
|
+
if publication is None:
|
|
195
217
|
print(
|
|
196
218
|
f"Would add {destination}, wire modules into {source_path} and update {metadata_path}"
|
|
197
219
|
)
|
|
198
220
|
return
|
|
199
221
|
context = {"module_name": package, "class_prefix": class_prefix}
|
|
200
222
|
if template == "ddd":
|
|
201
|
-
from ..fastapi.
|
|
223
|
+
from ..fastapi.service_routes import _operation_id, _service_path
|
|
202
224
|
|
|
203
225
|
# HTTP 身份由正式约定推导;basic 不读取或生成传输层配置。
|
|
204
226
|
http_identity = type(class_prefix + "ApplicationService", (), {})
|
|
@@ -206,30 +228,18 @@ def add_module(name: str, dry_run: bool, *, template: Literal["basic", "ddd"] =
|
|
|
206
228
|
http_service_path=_service_path("", http_identity),
|
|
207
229
|
http_operation_prefix=_operation_id(http_identity, ""),
|
|
208
230
|
)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
with tempfile.NamedTemporaryFile(
|
|
224
|
-
mode="w", encoding="utf-8", dir=source_path.parent, delete=False
|
|
225
|
-
) as pending:
|
|
226
|
-
pending.write(updated)
|
|
227
|
-
Path(pending.name).replace(source_path)
|
|
228
|
-
metadata_path.write_text(updated_metadata, encoding="utf-8")
|
|
229
|
-
subprocess.run(
|
|
230
|
-
["uv", "sync", "--directory", str(project.root)],
|
|
231
|
-
cwd=project.root,
|
|
232
|
-
env=project_environment(),
|
|
233
|
-
check=True,
|
|
234
|
-
)
|
|
231
|
+
with publication.step("render"):
|
|
232
|
+
staged = _render(
|
|
233
|
+
"basic" if template == "basic" else "module", context, staging=publication.rendering
|
|
234
|
+
)
|
|
235
|
+
with publication.step("prepare"):
|
|
236
|
+
publication.prepare(
|
|
237
|
+
staged,
|
|
238
|
+
(
|
|
239
|
+
("startup", source_path, source_bytes, updated.encode("utf-8")),
|
|
240
|
+
("metadata", metadata_path, metadata_bytes, updated_metadata.encode("utf-8")),
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
publication.publish(staged)
|
|
244
|
+
publication.sync()
|
|
235
245
|
print(f"Added {package}; staging retained at {staged}")
|
|
@@ -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
|
+
"""
|