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.
Files changed (104) hide show
  1. python_ddd_framework/application/runtime.py +46 -19
  2. python_ddd_framework/application_services/bindings.py +0 -64
  3. python_ddd_framework/application_services/dispatcher.py +1 -1
  4. python_ddd_framework/application_services/invocation.py +7 -500
  5. python_ddd_framework/application_services/module.py +16 -5
  6. python_ddd_framework/authorization/module.py +1 -1
  7. python_ddd_framework/background_execution/local.py +20 -1
  8. python_ddd_framework/background_execution/processes.py +11 -1
  9. python_ddd_framework/background_jobs/execution.py +1 -1
  10. python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
  11. python_ddd_framework/background_workers/execution.py +1 -1
  12. python_ddd_framework/background_workers/runtime.py +11 -2
  13. python_ddd_framework/caching/unit_of_work.py +2 -4
  14. python_ddd_framework/cli/project.py +11 -10
  15. python_ddd_framework/developer_kit/generation.py +46 -36
  16. python_ddd_framework/developer_kit/publication.py +442 -0
  17. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
  18. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
  19. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
  20. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
  21. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
  22. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
  23. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
  24. python_ddd_framework/diagnostics/source.py +14 -1
  25. python_ddd_framework/domain/aggregates.py +5 -12
  26. python_ddd_framework/domain/event_values.py +124 -0
  27. python_ddd_framework/events/runtime.py +2 -7
  28. python_ddd_framework/events/unit_of_work.py +1 -4
  29. python_ddd_framework/fastapi/action.py +2 -1
  30. python_ddd_framework/fastapi/adapter.py +11 -117
  31. python_ddd_framework/fastapi/application_services.py +17 -402
  32. python_ddd_framework/fastapi/error_handlers.py +128 -0
  33. python_ddd_framework/fastapi/realtime/runtime.py +1 -1
  34. python_ddd_framework/fastapi/request_context.py +2 -5
  35. python_ddd_framework/fastapi/routing.py +4 -10
  36. python_ddd_framework/fastapi/service_endpoints.py +182 -0
  37. python_ddd_framework/fastapi/service_routes.py +237 -0
  38. python_ddd_framework/fastapi/transfer.py +1 -1
  39. python_ddd_framework/hosted_services/bridge.py +76 -21
  40. python_ddd_framework/hosted_services/contracts.py +5 -1
  41. python_ddd_framework/hosted_services/runtime.py +71 -17
  42. python_ddd_framework/identity/__init__.py +21 -13
  43. python_ddd_framework/identity/application.py +12 -66
  44. python_ddd_framework/identity/contracts.py +38 -411
  45. python_ddd_framework/identity/domain_rules.py +17 -0
  46. python_ddd_framework/identity/dtos.py +152 -0
  47. python_ddd_framework/identity/extensions.py +9 -0
  48. python_ddd_framework/identity/management.py +28 -25
  49. python_ddd_framework/identity/models.py +86 -0
  50. python_ddd_framework/identity/module.py +3 -4
  51. python_ddd_framework/identity/options.py +54 -0
  52. python_ddd_framework/identity/seeding.py +59 -0
  53. python_ddd_framework/identity/services.py +1 -1
  54. python_ddd_framework/identity/sqlalchemy/module.py +9 -6
  55. python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
  56. python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
  57. python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
  58. python_ddd_framework/identity/stores.py +120 -0
  59. python_ddd_framework/identity/tokens.py +1 -1
  60. python_ddd_framework/invocation/callables.py +1 -1
  61. python_ddd_framework/invocation/dispatcher.py +1 -1
  62. python_ddd_framework/invocation/entrypoints.py +2 -1
  63. python_ddd_framework/invocation/function_runtime.py +2 -2
  64. python_ddd_framework/invocation/interception.py +1 -1
  65. python_ddd_framework/invocation/managed_proxy.py +2 -1
  66. python_ddd_framework/invocation/managed_services.py +8 -14
  67. python_ddd_framework/invocation/methods.py +2 -7
  68. python_ddd_framework/invocation/module.py +1 -1
  69. python_ddd_framework/invocation/runtime.py +483 -0
  70. python_ddd_framework/invocation/scopes.py +91 -0
  71. python_ddd_framework/lifecycle/participants.py +4 -0
  72. python_ddd_framework/messaging/channel.py +35 -8
  73. python_ddd_framework/messaging/contracts.py +9 -0
  74. python_ddd_framework/messaging/module.py +13 -0
  75. python_ddd_framework/messaging/runtime.py +17 -2
  76. python_ddd_framework/modularity/discovery.py +2 -7
  77. python_ddd_framework/modularity/graph.py +2 -8
  78. python_ddd_framework/observability/logging.py +1 -1
  79. python_ddd_framework/observability/tracing.py +1 -1
  80. python_ddd_framework/redis/cache.py +2 -0
  81. python_ddd_framework/redis/notification_runtime.py +1 -1
  82. python_ddd_framework/services/arbitration.py +1 -20
  83. python_ddd_framework/services/composition.py +71 -2
  84. python_ddd_framework/services/provider.py +23 -1
  85. python_ddd_framework/services/relocation.py +96 -0
  86. python_ddd_framework/settings/refresh.py +1 -1
  87. python_ddd_framework/settings/sqlalchemy/store.py +1 -1
  88. python_ddd_framework/sqlalchemy/metadata.py +11 -413
  89. python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
  90. python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
  91. python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
  92. python_ddd_framework/sqlalchemy/migration.py +2 -1
  93. python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
  94. python_ddd_framework/sqlalchemy/module.py +2 -1
  95. python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
  96. python_ddd_framework/unit_of_work/contracts.py +68 -7
  97. python_ddd_framework/unit_of_work/manager.py +4 -7
  98. python_ddd_framework/unit_of_work/module.py +1 -1
  99. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
  100. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
  101. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
  102. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
  103. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
  104. {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
- for lifecycle in work._lifecycles:
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 = Path(tempfile.mkdtemp(prefix="python-ddd-framework-generation-"))
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
- modules = project.root / "src" / "modules"
170
- destination = modules / package
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
- metadata_source = metadata_path.read_text(encoding="utf-8")
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 dry_run:
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.application_services import _operation_id, _service_path
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
- staged = _render(
210
- "basic" if template == "basic" else "module",
211
- context,
212
- )
213
- # 先验证全部新文件和接线;已有 startup 文件仅在内容仍等于读取快照时替换。
214
- if (
215
- source_path.read_text(encoding="utf-8") != source
216
- or metadata_path.read_text(encoding="utf-8") != metadata_source
217
- ):
218
- raise CommandError("Startup source changed during generation; retry after reviewing it")
219
- modules.mkdir(exist_ok=True)
220
- if not modules.joinpath("__init__.py").exists():
221
- modules.joinpath("__init__.py").touch()
222
- shutil.copytree(staged, destination)
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
+ """