netizen-cli 0.10.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 (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,269 @@
1
+ """Model-catalog projection and native Turn setting validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from openai_codex.generated.v2_all import ModelUpgradeInfo
9
+
10
+
11
+ # App Server uses this protocol-level request value to explicitly select
12
+ # Standard processing instead of inheriting a previous accelerated tier. It
13
+ # is not a model capability; accelerated/alternative tiers still come only
14
+ # from ``codex.models()``. Keep this aligned with the exact release source:
15
+ # https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/protocol/src/config_types.rs
16
+ STANDARD_SERVICE_TIER_ID = "default"
17
+
18
+
19
+ class ModelCatalogError(ValueError):
20
+ pass
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class EffortOption:
25
+ id: str
26
+ description: str
27
+ wire_value: object
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ServiceTierOption:
32
+ id: str
33
+ name: str
34
+ description: str
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ModelOption:
39
+ id: str
40
+ model: str
41
+ display_name: str
42
+ description: str
43
+ is_default: bool
44
+ default_effort_id: str
45
+ default_service_tier_id: str
46
+ efforts: tuple[EffortOption, ...]
47
+ service_tiers: tuple[ServiceTierOption, ...]
48
+ input_modalities: tuple[str, ...] | None = None
49
+ upgrade: str | None = None
50
+ upgrade_info: ModelUpgradeInfo | None = None
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class TurnModelSettings:
55
+ """Validated wire values for one native ``turn/start`` override."""
56
+
57
+ model_id: str
58
+ model: str
59
+ effort_id: str
60
+ effort: object
61
+ service_tier_id: str
62
+ service_tier_name: str
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class ModelCatalog:
67
+ models: tuple[ModelOption, ...]
68
+
69
+ @classmethod
70
+ def from_response(cls, response: object) -> ModelCatalog:
71
+ if getattr(response, "next_cursor", None) is not None:
72
+ # The pinned public ``models()`` facade has no cursor parameter.
73
+ # Treat a paginated response as incomplete instead of silently
74
+ # presenting only the first page as the full native catalog.
75
+ raise ModelCatalogError(
76
+ "Codex 模型目录需要分页,但当前高层 SDK 无法读取后续页面。"
77
+ )
78
+ raw_models = getattr(response, "data", None)
79
+ if not isinstance(raw_models, list) or not raw_models:
80
+ raise ModelCatalogError("Codex 没有返回可用模型。")
81
+
82
+ models = tuple(_model_option(raw) for raw in raw_models)
83
+ ids = [model.id for model in models]
84
+ if len(ids) != len(set(ids)):
85
+ raise ModelCatalogError("Codex 模型目录包含重复 ID。")
86
+ defaults = [model for model in models if model.is_default]
87
+ if len(defaults) != 1:
88
+ raise ModelCatalogError("Codex 模型目录必须包含且仅包含一个默认模型。")
89
+ return cls(models)
90
+
91
+ @property
92
+ def default_model(self) -> ModelOption:
93
+ return next(model for model in self.models if model.is_default)
94
+
95
+ @property
96
+ def effort_options(self) -> tuple[EffortOption, ...]:
97
+ seen: set[str] = set()
98
+ options: list[EffortOption] = []
99
+ for model in self.models:
100
+ for option in model.efforts:
101
+ if option.id in seen:
102
+ continue
103
+ seen.add(option.id)
104
+ options.append(option)
105
+ return tuple(options)
106
+
107
+ @property
108
+ def service_tier_options(self) -> tuple[ServiceTierOption, ...]:
109
+ options = [
110
+ ServiceTierOption(
111
+ STANDARD_SERVICE_TIER_ID,
112
+ "Standard",
113
+ "Codex 标准服务层",
114
+ )
115
+ ]
116
+ seen = {STANDARD_SERVICE_TIER_ID}
117
+ for model in self.models:
118
+ for option in model.service_tiers:
119
+ if option.id in seen:
120
+ continue
121
+ seen.add(option.id)
122
+ options.append(option)
123
+ return tuple(options)
124
+
125
+ def resolve(
126
+ self,
127
+ *,
128
+ model_id: str,
129
+ effort_id: str,
130
+ service_tier_id: str,
131
+ ) -> TurnModelSettings:
132
+ model = next((item for item in self.models if item.id == model_id), None)
133
+ if model is None:
134
+ raise ModelCatalogError("所选 Model 已不可用,请重新打开卡片。")
135
+ effort = next((item for item in model.efforts if item.id == effort_id), None)
136
+ if effort is None:
137
+ raise ModelCatalogError(
138
+ f"Model {model.display_name} 不支持 Effort {effort_id},"
139
+ "请重新选择。"
140
+ )
141
+ if service_tier_id == STANDARD_SERVICE_TIER_ID:
142
+ service_tier_name = "Standard"
143
+ else:
144
+ service_tier = next(
145
+ (
146
+ item
147
+ for item in model.service_tiers
148
+ if item.id == service_tier_id
149
+ ),
150
+ None,
151
+ )
152
+ if service_tier is None:
153
+ raise ModelCatalogError(
154
+ f"Model {model.display_name} 不支持 Speed {service_tier_id},"
155
+ "请重新选择。"
156
+ )
157
+ service_tier_name = service_tier.name
158
+ return TurnModelSettings(
159
+ model_id=model.id,
160
+ model=model.model,
161
+ effort_id=effort.id,
162
+ effort=effort.wire_value,
163
+ service_tier_id=service_tier_id,
164
+ service_tier_name=service_tier_name,
165
+ )
166
+
167
+
168
+ def _model_option(raw: Any) -> ModelOption:
169
+ model_id = _nonempty_string(getattr(raw, "id", None), "model.id")
170
+ model = _nonempty_string(getattr(raw, "model", None), "model.model")
171
+ display_name = _nonempty_string(
172
+ getattr(raw, "display_name", None),
173
+ "model.display_name",
174
+ )
175
+ description = str(getattr(raw, "description", "") or "")
176
+ raw_modalities = getattr(raw, "input_modalities", None)
177
+ modalities = (
178
+ tuple(getattr(item, "value", item) for item in raw_modalities)
179
+ if isinstance(raw_modalities, list) else None
180
+ )
181
+
182
+ efforts = tuple(
183
+ _effort_option(option)
184
+ for option in (getattr(raw, "supported_reasoning_efforts", None) or [])
185
+ )
186
+ if not efforts:
187
+ raise ModelCatalogError(f"Model {display_name} 没有可用 Effort。")
188
+ effort_ids = [option.id for option in efforts]
189
+ if len(effort_ids) != len(set(effort_ids)):
190
+ raise ModelCatalogError(f"Model {display_name} 包含重复 Effort。")
191
+
192
+ default_effort_id = _enum_value(
193
+ getattr(raw, "default_reasoning_effort", None),
194
+ "model.default_reasoning_effort",
195
+ )
196
+ if default_effort_id not in set(effort_ids):
197
+ raise ModelCatalogError(
198
+ f"Model {display_name} 的默认 Effort 不在支持列表中。"
199
+ )
200
+
201
+ tiers = tuple(
202
+ _service_tier_option(option)
203
+ for option in (getattr(raw, "service_tiers", None) or [])
204
+ )
205
+ tier_ids = [option.id for option in tiers]
206
+ if len(tier_ids) != len(set(tier_ids)):
207
+ raise ModelCatalogError(f"Model {display_name} 包含重复 Service Tier。")
208
+ default_tier = getattr(raw, "default_service_tier", None)
209
+ if default_tier is None:
210
+ default_tier_id = STANDARD_SERVICE_TIER_ID
211
+ else:
212
+ default_tier_id = _nonempty_string(
213
+ default_tier,
214
+ "model.default_service_tier",
215
+ )
216
+ if default_tier_id not in set(tier_ids):
217
+ raise ModelCatalogError(
218
+ f"Model {display_name} 的默认 Service Tier 不在支持列表中。"
219
+ )
220
+
221
+ return ModelOption(
222
+ id=model_id,
223
+ model=model,
224
+ display_name=display_name,
225
+ description=description,
226
+ is_default=bool(getattr(raw, "is_default", False)),
227
+ default_effort_id=default_effort_id,
228
+ default_service_tier_id=default_tier_id,
229
+ efforts=efforts,
230
+ service_tiers=tiers,
231
+ input_modalities=(
232
+ modalities
233
+ if modalities is not None and all(isinstance(item, str) for item in modalities)
234
+ else None
235
+ ),
236
+ upgrade=(getattr(raw, "upgrade", None) or None),
237
+ upgrade_info=(
238
+ raw.upgrade_info
239
+ if isinstance(getattr(raw, "upgrade_info", None), ModelUpgradeInfo)
240
+ else None
241
+ ),
242
+ )
243
+
244
+
245
+ def _effort_option(raw: Any) -> EffortOption:
246
+ wire_value = getattr(raw, "reasoning_effort", None)
247
+ return EffortOption(
248
+ id=_enum_value(wire_value, "reasoning_effort"),
249
+ description=str(getattr(raw, "description", "") or ""),
250
+ wire_value=wire_value,
251
+ )
252
+
253
+
254
+ def _service_tier_option(raw: Any) -> ServiceTierOption:
255
+ return ServiceTierOption(
256
+ id=_nonempty_string(getattr(raw, "id", None), "service_tier.id"),
257
+ name=_nonempty_string(getattr(raw, "name", None), "service_tier.name"),
258
+ description=str(getattr(raw, "description", "") or ""),
259
+ )
260
+
261
+
262
+ def _enum_value(value: Any, field: str) -> str:
263
+ return _nonempty_string(getattr(value, "value", value), field)
264
+
265
+
266
+ def _nonempty_string(value: Any, field: str) -> str:
267
+ if not isinstance(value, str) or not value:
268
+ raise ModelCatalogError(f"Codex 模型目录字段 {field} 无效。")
269
+ return value
@@ -0,0 +1,22 @@
1
+ """Read files shipped with this exact installation, or an explicit checkout."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def resource_path(relative: str) -> Path:
7
+ requested = Path(relative)
8
+ if requested.is_absolute() or ".." in requested.parts:
9
+ raise ValueError("package resource path must be relative without parent traversal")
10
+ package = Path(__file__).resolve().parent
11
+ root = package / "resources"
12
+ if root.is_symlink():
13
+ raise ValueError("package resource directory must not be a symlink")
14
+ if not root.exists():
15
+ source = package.parent
16
+ if package.name != "netizen_cli" or not (source / "pyproject.toml").is_file():
17
+ raise FileNotFoundError("installed Netizen resource directory is missing")
18
+ root = source
19
+ resource = root / requested
20
+ if resource.is_symlink() or not resource.is_file() or not resource.resolve().is_relative_to(root):
21
+ raise FileNotFoundError(f"Netizen package resource is missing or unsafe: {relative}")
22
+ return resource
@@ -0,0 +1,327 @@
1
+ """Persistent Channel aliases for native Codex working directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .bindings import (
11
+ PROJECT_DELETE_LIMIT,
12
+ BindingStore,
13
+ ProjectConflict as StoredProjectConflict,
14
+ ProjectDeleteSnapshot,
15
+ ProjectNotFound as StoredProjectNotFound,
16
+ ProjectRecord,
17
+ ProjectRevisionConflict as StoredProjectRevisionConflict,
18
+ )
19
+
20
+
21
+ _ALIAS = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
22
+
23
+
24
+ class ProjectError(ValueError):
25
+ pass
26
+
27
+
28
+ class UnknownProject(ProjectError):
29
+ pass
30
+
31
+
32
+ class ProjectDisabled(ProjectError):
33
+ pass
34
+
35
+
36
+ class ProjectAlreadyExists(ProjectError):
37
+ pass
38
+
39
+
40
+ class StaleProject(ProjectError):
41
+ pass
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Project:
46
+ alias: str
47
+ cwd: Path
48
+ enabled: bool
49
+ revision: int
50
+
51
+
52
+ class ProjectRegistry:
53
+ def __init__(
54
+ self,
55
+ *,
56
+ store: BindingStore,
57
+ project_root: Path,
58
+ projects: dict[str, Path],
59
+ ) -> None:
60
+ self._store = store
61
+ self._project_root = _canonical_directory(project_root, "projectRoot")
62
+ for alias, configured_path in projects.items():
63
+ self._bootstrap(alias=alias, configured_path=configured_path)
64
+
65
+ @property
66
+ def project_root(self) -> Path:
67
+ return self._project_root
68
+
69
+ def list(self, *, enabled_only: bool = False) -> tuple[Project, ...]:
70
+ projects = tuple(_project(record) for record in self._store.list_projects())
71
+ if not enabled_only:
72
+ return projects
73
+ return tuple(project for project in projects if project.enabled)
74
+
75
+ def resolve_for_new(
76
+ self,
77
+ alias: str,
78
+ *,
79
+ expected_revision: int | None = None,
80
+ ) -> Project:
81
+ project = self._resolve(alias)
82
+ if expected_revision is not None and project.revision != expected_revision:
83
+ raise StaleProject(
84
+ f"Project {alias} 已被其他操作修改,请刷新卡片后重试。"
85
+ )
86
+ if not project.enabled:
87
+ raise ProjectDisabled(f"Project {alias} 已停用,不能创建新会话。")
88
+ return _usable_project(project)
89
+
90
+ def resolve_for_binding(self, alias: str) -> Project:
91
+ return _usable_project(self._resolve(alias))
92
+
93
+ def resolve(self, alias: str) -> Project:
94
+ """Compatibility alias for existing bindings; disabled rows still resolve."""
95
+ return self.resolve_for_binding(alias)
96
+
97
+ def aliases(self) -> tuple[str, ...]:
98
+ return tuple(project.alias for project in self.list())
99
+
100
+ def register(
101
+ self,
102
+ *,
103
+ alias: str,
104
+ path: str | None,
105
+ create_directory: bool,
106
+ ) -> Project:
107
+ alias = alias.strip()
108
+ _validate_alias(alias)
109
+ try:
110
+ self._store.get_project(alias)
111
+ except StoredProjectNotFound:
112
+ pass
113
+ else:
114
+ raise ProjectAlreadyExists(f"Project {alias} 已存在。")
115
+
116
+ created_directory: Path | None = None
117
+ if create_directory:
118
+ target = self._creation_target(alias=alias, path=path)
119
+ if target.exists():
120
+ raise ProjectError(
121
+ f"目录 {target} 已存在;请改用“登记已有目录”。"
122
+ )
123
+ try:
124
+ target.mkdir(mode=0o700)
125
+ except OSError as error:
126
+ raise ProjectError(f"无法创建 Project 目录 {target}:{error}") from error
127
+ created_directory = target
128
+ try:
129
+ cwd = _canonical_directory(target, alias)
130
+ except ProjectError as error:
131
+ raise ProjectError(
132
+ f"{error} 新建目录 {target} 已保留,未自动删除。"
133
+ ) from error
134
+ else:
135
+ if path is None or not path.strip():
136
+ raise ProjectError("登记已有目录时必须填写绝对路径。")
137
+ cwd = _canonical_directory(Path(path.strip()).expanduser(), alias)
138
+
139
+ try:
140
+ record = self._store.register_project(alias=alias, cwd=str(cwd))
141
+ except StoredProjectConflict as error:
142
+ if created_directory is not None:
143
+ raise ProjectAlreadyExists(
144
+ f"Project {alias} 已被其他操作登记;新建目录 {created_directory} "
145
+ "已保留,未自动删除。"
146
+ ) from error
147
+ raise ProjectAlreadyExists(f"Project {alias} 已存在。") from error
148
+ except Exception as error:
149
+ if created_directory is not None:
150
+ raise ProjectError(
151
+ f"Project {alias} 登记失败;新建目录 {created_directory} "
152
+ "已保留,未自动删除。"
153
+ ) from error
154
+ raise
155
+ return _project(record)
156
+
157
+ def set_enabled(
158
+ self,
159
+ *,
160
+ alias: str,
161
+ enabled: bool,
162
+ expected_revision: int,
163
+ ) -> Project:
164
+ try:
165
+ record = self._store.set_project_enabled(
166
+ alias=alias,
167
+ enabled=enabled,
168
+ expected_revision=expected_revision,
169
+ )
170
+ except StoredProjectNotFound as error:
171
+ raise UnknownProject(alias) from error
172
+ except StoredProjectRevisionConflict as error:
173
+ raise StaleProject(
174
+ f"Project {alias} 已被其他操作修改,请刷新卡片后重试。"
175
+ ) from error
176
+ except StoredProjectConflict as error:
177
+ raise ProjectError(str(error)) from error
178
+ return _project(record)
179
+
180
+ def preview_delete(
181
+ self,
182
+ alias: str,
183
+ *,
184
+ limit: int = PROJECT_DELETE_LIMIT,
185
+ extra_side_ids: tuple[str, ...] = (),
186
+ ) -> ProjectDeleteSnapshot:
187
+ try:
188
+ return self._store.preview_project_delete(
189
+ alias, limit=limit, extra_side_ids=extra_side_ids,
190
+ )
191
+ except StoredProjectNotFound as error:
192
+ raise UnknownProject(alias) from error
193
+ except StoredProjectConflict as error:
194
+ raise ProjectError(str(error)) from error
195
+
196
+ def begin_delete(
197
+ self,
198
+ *,
199
+ alias: str,
200
+ expected_revision: int,
201
+ expected_inventory_fingerprint: str,
202
+ limit: int = PROJECT_DELETE_LIMIT,
203
+ extra_side_ids: tuple[str, ...] = (),
204
+ ) -> ProjectDeleteSnapshot:
205
+ try:
206
+ return self._store.begin_project_delete(
207
+ alias=alias,
208
+ expected_revision=expected_revision,
209
+ expected_inventory_fingerprint=expected_inventory_fingerprint,
210
+ limit=limit,
211
+ extra_side_ids=extra_side_ids,
212
+ )
213
+ except StoredProjectNotFound as error:
214
+ raise UnknownProject(alias) from error
215
+ except StoredProjectRevisionConflict as error:
216
+ raise StaleProject(f"Project {alias} 已改变,请刷新后重新确认。") from error
217
+ except StoredProjectConflict as error:
218
+ raise ProjectError(str(error)) from error
219
+
220
+ def finish_delete(
221
+ self,
222
+ *,
223
+ alias: str,
224
+ expected_revision: int,
225
+ expected_inventory_fingerprint: str,
226
+ ) -> None:
227
+ try:
228
+ self._store.finish_project_delete(
229
+ alias=alias,
230
+ expected_revision=expected_revision,
231
+ expected_inventory_fingerprint=expected_inventory_fingerprint,
232
+ )
233
+ except StoredProjectNotFound as error:
234
+ raise UnknownProject(alias) from error
235
+ except StoredProjectRevisionConflict as error:
236
+ raise StaleProject(f"Project {alias} 已改变,请刷新后重新确认。") from error
237
+ except StoredProjectConflict as error:
238
+ raise ProjectError(str(error)) from error
239
+
240
+ def release_delete(self, *, alias: str, expected_revision: int) -> None:
241
+ try:
242
+ self._store.release_project_delete(
243
+ alias=alias, expected_revision=expected_revision
244
+ )
245
+ except StoredProjectRevisionConflict as error:
246
+ raise StaleProject(f"Project {alias} 已改变,请刷新后重新确认。") from error
247
+
248
+ def _resolve(self, alias: str) -> Project:
249
+ try:
250
+ return _project(self._store.get_project(alias))
251
+ except StoredProjectNotFound as error:
252
+ raise UnknownProject(alias) from error
253
+
254
+ def _bootstrap(
255
+ self,
256
+ *,
257
+ alias: str,
258
+ configured_path: Path,
259
+ ) -> None:
260
+ _validate_alias(alias)
261
+ try:
262
+ self._store.get_project(alias, include_deleted=True)
263
+ except StoredProjectNotFound:
264
+ cwd = _canonical_directory(configured_path, alias)
265
+ self._store.bootstrap_project(alias=alias, cwd=str(cwd))
266
+
267
+ def _creation_target(self, *, alias: str, path: str | None) -> Path:
268
+ raw = (
269
+ Path(path.strip()).expanduser()
270
+ if path is not None and path.strip()
271
+ else self._project_root / alias
272
+ )
273
+ if not raw.is_absolute():
274
+ raise ProjectError("创建 Project 的路径必须是绝对路径。")
275
+ target = raw.resolve(strict=False)
276
+ if target == self._project_root or not target.is_relative_to(
277
+ self._project_root
278
+ ):
279
+ raise ProjectError(
280
+ f"只能在 projectRoot {self._project_root} 内创建目录。"
281
+ )
282
+ try:
283
+ parent = target.parent.resolve(strict=True)
284
+ except OSError as error:
285
+ raise ProjectError(f"Project 父目录不存在:{target.parent}") from error
286
+ if not parent.is_relative_to(self._project_root):
287
+ raise ProjectError(
288
+ f"只能在 projectRoot {self._project_root} 内创建目录。"
289
+ )
290
+ if not parent.is_dir() or not os.access(parent, os.R_OK | os.W_OK | os.X_OK):
291
+ raise ProjectError(f"Project 父目录不可读写:{parent}")
292
+ return target
293
+
294
+
295
+ def _validate_alias(alias: str) -> None:
296
+ if not _ALIAS.fullmatch(alias):
297
+ raise ProjectError(f"无效 Project alias:{alias}")
298
+
299
+
300
+ def _project(record: ProjectRecord) -> Project:
301
+ return Project(
302
+ alias=record.alias,
303
+ cwd=Path(record.cwd),
304
+ enabled=record.enabled,
305
+ revision=record.revision,
306
+ )
307
+
308
+
309
+ def _usable_project(project: Project) -> Project:
310
+ cwd = _canonical_directory(project.cwd, project.alias)
311
+ if cwd == project.cwd:
312
+ return project
313
+ return Project(project.alias, cwd, project.enabled, project.revision)
314
+
315
+
316
+ def _canonical_directory(path: Path, alias: str) -> Path:
317
+ if not path.is_absolute():
318
+ raise ProjectError(f"Project {alias} cwd 必须是绝对路径。")
319
+ try:
320
+ cwd = path.expanduser().resolve(strict=True)
321
+ except OSError as error:
322
+ raise ProjectError(f"Project {alias} cwd 不存在:{path}") from error
323
+ if not cwd.is_dir():
324
+ raise ProjectError(f"Project {alias} cwd 不是目录。")
325
+ if not os.access(cwd, os.R_OK | os.W_OK | os.X_OK):
326
+ raise ProjectError(f"Project {alias} cwd 必须可读、可写、可进入。")
327
+ return cwd