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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- 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
|
netizen_cli/projects.py
ADDED
|
@@ -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
|