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,812 @@
|
|
|
1
|
+
"""Derive stateless current files from one native Turn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from openai_codex.generated.v2_all import FileChangeThreadItem
|
|
14
|
+
from openai_codex.types import ThreadItem
|
|
15
|
+
|
|
16
|
+
from .turn_patch_children import TaskPatchChildren, TurnPatchBatch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TURN_FILE_PAGE_SIZE = 8
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TurnFileError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class TurnFile:
|
|
28
|
+
display_path: str
|
|
29
|
+
resolved_path: Path
|
|
30
|
+
size: int | None
|
|
31
|
+
media_kind: Literal["image", "file"] | None
|
|
32
|
+
additions: int | None = None
|
|
33
|
+
deletions: int | None = None
|
|
34
|
+
deleted: bool = False
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def available(self) -> bool:
|
|
38
|
+
return self.size is not None and self.media_kind is not None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class TurnFilePage:
|
|
43
|
+
items: tuple[TurnFile, ...]
|
|
44
|
+
page: int
|
|
45
|
+
total_pages: int
|
|
46
|
+
total_items: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class TurnDiffFileStats:
|
|
51
|
+
"""Optional line counts associated with one reported file path."""
|
|
52
|
+
|
|
53
|
+
path: str
|
|
54
|
+
additions: int | None = None
|
|
55
|
+
deletions: int | None = None
|
|
56
|
+
deleted: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class TurnDiffSummary:
|
|
61
|
+
"""Display-safe paths and optional paired line counts."""
|
|
62
|
+
|
|
63
|
+
additions: int | None = None
|
|
64
|
+
deletions: int | None = None
|
|
65
|
+
files: tuple[TurnDiffFileStats, ...] = ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class _ReportedTurnPath:
|
|
70
|
+
item_type: Literal["turnDiff", "fileChange", "imageGeneration"]
|
|
71
|
+
value: str
|
|
72
|
+
additions: int | None = None
|
|
73
|
+
deletions: int | None = None
|
|
74
|
+
deleted: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class _ParsedDiffBlock:
|
|
79
|
+
path: str | None
|
|
80
|
+
deleted: bool
|
|
81
|
+
binary: bool
|
|
82
|
+
counts: tuple[int, int] | None
|
|
83
|
+
malformed: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_HUNK_HEADER = re.compile(
|
|
87
|
+
r"^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@(?:.*)$"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def turn_patch_summary(
|
|
92
|
+
items: Sequence[object],
|
|
93
|
+
project_cwd: Path,
|
|
94
|
+
*,
|
|
95
|
+
children: TaskPatchChildren = TaskPatchChildren(),
|
|
96
|
+
turn_diff: str | None = None,
|
|
97
|
+
) -> TurnDiffSummary:
|
|
98
|
+
"""Count successful patch operations, including proven new descendants.
|
|
99
|
+
|
|
100
|
+
Repeated edits accumulate; this is not a final net diff. Native aggregate
|
|
101
|
+
diffs supplement paths only, so their overlapping counts are never added.
|
|
102
|
+
Unreadable children or patches omit the total, retaining known file counts.
|
|
103
|
+
"""
|
|
104
|
+
counts: dict[str, tuple[int, int] | None] = {}
|
|
105
|
+
deleted_paths: set[str] = set()
|
|
106
|
+
additions = deletions = 0
|
|
107
|
+
complete = children.complete
|
|
108
|
+
saw_counts = False
|
|
109
|
+
root_paths: set[str] = set()
|
|
110
|
+
seen: set[tuple[str, str, str]] = set()
|
|
111
|
+
batches = (TurnPatchBatch("", "", project_cwd, tuple(items)), *children.batches)
|
|
112
|
+
for batch in batches:
|
|
113
|
+
# Final item views replace earlier lifecycle views of the same item.
|
|
114
|
+
patches: dict[str, FileChangeThreadItem] = {}
|
|
115
|
+
conflicting: set[str] = set()
|
|
116
|
+
for wrapped in batch.items:
|
|
117
|
+
item = getattr(wrapped, "root", wrapped)
|
|
118
|
+
if type(item) is not FileChangeThreadItem:
|
|
119
|
+
continue
|
|
120
|
+
previous_item = patches.get(item.id)
|
|
121
|
+
if (
|
|
122
|
+
previous_item is not None
|
|
123
|
+
and _enum_value(previous_item.status) == "completed"
|
|
124
|
+
and _enum_value(item.status) == "completed"
|
|
125
|
+
and previous_item != item
|
|
126
|
+
):
|
|
127
|
+
conflicting.add(item.id)
|
|
128
|
+
patches[item.id] = item
|
|
129
|
+
for item in patches.values():
|
|
130
|
+
identity = (batch.thread_id, batch.turn_id, item.id)
|
|
131
|
+
if identity in seen:
|
|
132
|
+
continue
|
|
133
|
+
seen.add(identity)
|
|
134
|
+
if _enum_value(item.status) != "completed":
|
|
135
|
+
if _enum_value(item.status) == "inProgress":
|
|
136
|
+
complete = False
|
|
137
|
+
continue
|
|
138
|
+
for change in item.changes:
|
|
139
|
+
kind = change.kind.root
|
|
140
|
+
raw_path = getattr(kind, "move_path", None) or change.path
|
|
141
|
+
try:
|
|
142
|
+
path = _patch_path(raw_path, batch.cwd)
|
|
143
|
+
except (OSError, RuntimeError, ValueError):
|
|
144
|
+
complete = False
|
|
145
|
+
continue
|
|
146
|
+
if not batch.thread_id:
|
|
147
|
+
root_paths.add(path)
|
|
148
|
+
if kind.type == "delete" and item.id not in conflicting:
|
|
149
|
+
deleted_paths.add(path)
|
|
150
|
+
value = (
|
|
151
|
+
None if item.id in conflicting else _file_change_counts(
|
|
152
|
+
change.diff, kind.type, getattr(kind, "move_path", None),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
if value is None:
|
|
156
|
+
complete = False
|
|
157
|
+
else:
|
|
158
|
+
additions += value[0]
|
|
159
|
+
deletions += value[1]
|
|
160
|
+
saw_counts = True
|
|
161
|
+
if path not in counts:
|
|
162
|
+
counts[path] = value
|
|
163
|
+
elif counts[path] is None or value is None:
|
|
164
|
+
counts[path] = None
|
|
165
|
+
else:
|
|
166
|
+
previous = counts[path]
|
|
167
|
+
assert previous is not None
|
|
168
|
+
counts[path] = (previous[0] + value[0], previous[1] + value[1])
|
|
169
|
+
|
|
170
|
+
# Preserve aggregate-only file discovery without mixing two count metrics.
|
|
171
|
+
for lines in _diff_blocks(turn_diff or ""):
|
|
172
|
+
block = _parse_diff_block(lines)
|
|
173
|
+
if block.path is None:
|
|
174
|
+
complete = False
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
path = _patch_path(block.path, project_cwd)
|
|
178
|
+
except (OSError, RuntimeError, ValueError):
|
|
179
|
+
complete = False
|
|
180
|
+
continue
|
|
181
|
+
if path not in root_paths:
|
|
182
|
+
complete = False
|
|
183
|
+
if not block.deleted or path in counts:
|
|
184
|
+
counts[path] = None
|
|
185
|
+
return TurnDiffSummary(
|
|
186
|
+
additions if complete and saw_counts else None,
|
|
187
|
+
deletions if complete and saw_counts else None,
|
|
188
|
+
tuple(
|
|
189
|
+
TurnDiffFileStats(
|
|
190
|
+
path, *(value or (None, None)), deleted=path in deleted_paths,
|
|
191
|
+
)
|
|
192
|
+
for path, value in counts.items()
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _patch_path(value: str, cwd: Path) -> str:
|
|
198
|
+
if not value or "\0" in value:
|
|
199
|
+
raise ValueError("invalid patch path")
|
|
200
|
+
path = Path(value)
|
|
201
|
+
return str((path if path.is_absolute() else cwd / path).resolve())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _file_change_counts(
|
|
205
|
+
diff: str, kind: str, move_path: str | None,
|
|
206
|
+
) -> tuple[int, int] | None:
|
|
207
|
+
if "\0" in diff:
|
|
208
|
+
return None
|
|
209
|
+
if kind in {"add", "delete"}:
|
|
210
|
+
# SDK add/delete contain whole text, not a unified diff. Count LF only;
|
|
211
|
+
# a final newline terminates the last line rather than adding a line.
|
|
212
|
+
lines = diff.count("\n") + int(bool(diff) and not diff.endswith("\n"))
|
|
213
|
+
return (lines, 0) if kind == "add" else (0, lines)
|
|
214
|
+
if kind != "update":
|
|
215
|
+
return None
|
|
216
|
+
if move_path:
|
|
217
|
+
suffix = f"\n\nMoved to: {move_path}"
|
|
218
|
+
if diff.endswith(suffix):
|
|
219
|
+
diff = diff[:-len(suffix)]
|
|
220
|
+
if not diff:
|
|
221
|
+
return (0, 0)
|
|
222
|
+
# Generated update patches can have file headers or start directly with a
|
|
223
|
+
# hunk. Reuse the validated hunk parser; paths come from the typed change.
|
|
224
|
+
lines = [line.removesuffix("\r") for line in diff.split("\n")]
|
|
225
|
+
if lines and not lines[-1]:
|
|
226
|
+
lines.pop()
|
|
227
|
+
if diff.startswith("@@ "):
|
|
228
|
+
lines = ["--- a/file", "+++ b/file", *lines]
|
|
229
|
+
if not diff.startswith("diff --git "):
|
|
230
|
+
lines.insert(0, "diff --git a/file b/file")
|
|
231
|
+
if sum(line.startswith("diff --git ") for line in lines) != 1:
|
|
232
|
+
return None
|
|
233
|
+
block = _parse_diff_block(lines)
|
|
234
|
+
return block.counts if not block.binary else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def extract_turn_files(
|
|
238
|
+
items: Sequence[ThreadItem],
|
|
239
|
+
project_cwd: Path,
|
|
240
|
+
*,
|
|
241
|
+
turn_diff: str | None = None,
|
|
242
|
+
diff_summary: TurnDiffSummary | None = None,
|
|
243
|
+
home_directory: Path | None = None,
|
|
244
|
+
) -> tuple[TurnFile, ...]:
|
|
245
|
+
"""Return current files and missing files with successful deletion evidence.
|
|
246
|
+
|
|
247
|
+
The public ``ThreadItem.root`` discriminator is the only SDK shape used.
|
|
248
|
+
Project is the base for relative paths, not an authorization boundary.
|
|
249
|
+
Other missing and special files are omitted; this function never scans any
|
|
250
|
+
directory to infer extra outputs.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
root = project_cwd.resolve(strict=True)
|
|
255
|
+
except (OSError, RuntimeError) as error:
|
|
256
|
+
raise TurnFileError("Project 目录当前不可用。") from error
|
|
257
|
+
if not root.is_dir():
|
|
258
|
+
raise TurnFileError("Project 路径不是目录。")
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
home = (
|
|
262
|
+
Path.home() if home_directory is None else home_directory
|
|
263
|
+
).resolve(strict=True)
|
|
264
|
+
except (OSError, RuntimeError):
|
|
265
|
+
home = None
|
|
266
|
+
|
|
267
|
+
results: list[TurnFile] = []
|
|
268
|
+
seen: set[Path] = set()
|
|
269
|
+
for reported in _reported_paths(
|
|
270
|
+
items,
|
|
271
|
+
turn_diff=turn_diff,
|
|
272
|
+
diff_summary=diff_summary,
|
|
273
|
+
):
|
|
274
|
+
turn_file = _resolve_turn_file(reported, root, home)
|
|
275
|
+
if turn_file is None or turn_file.resolved_path in seen:
|
|
276
|
+
continue
|
|
277
|
+
seen.add(turn_file.resolved_path)
|
|
278
|
+
results.append(turn_file)
|
|
279
|
+
return tuple(results)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def has_turn_file_references(
|
|
283
|
+
items: Sequence[object],
|
|
284
|
+
*,
|
|
285
|
+
turn_diff: str | None = None,
|
|
286
|
+
diff_summary: TurnDiffSummary | None = None,
|
|
287
|
+
) -> bool:
|
|
288
|
+
"""Return whether supported structured items name at least one output path."""
|
|
289
|
+
|
|
290
|
+
return next(
|
|
291
|
+
_reported_paths(
|
|
292
|
+
items,
|
|
293
|
+
turn_diff=turn_diff,
|
|
294
|
+
diff_summary=diff_summary,
|
|
295
|
+
),
|
|
296
|
+
None,
|
|
297
|
+
) is not None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def turn_diff_paths(diff: str | None) -> tuple[str, ...]:
|
|
301
|
+
"""Return current-side paths from a Git-style aggregate unified diff.
|
|
302
|
+
|
|
303
|
+
App Server's ``turn/diff/updated`` payload is an aggregate snapshot. This
|
|
304
|
+
is the compatibility wrapper for callers that only need paths. Deleted
|
|
305
|
+
targets are excluded; rename destinations and binary-file headers remain
|
|
306
|
+
supported.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
return tuple(item.path for item in turn_diff_summary(diff).files)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def turn_diff_summary(diff: str | None) -> TurnDiffSummary:
|
|
313
|
+
"""Parse paths and line counts from the latest aggregate unified diff.
|
|
314
|
+
|
|
315
|
+
A malformed hunk invalidates all numeric statistics while preserving any
|
|
316
|
+
independently parseable current-side paths. Binary blocks keep their path
|
|
317
|
+
but never receive line counts. Aggregate totals include valid deleted-file
|
|
318
|
+
hunks even though deleted targets cannot appear in the current file list.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
if not isinstance(diff, str) or not diff:
|
|
322
|
+
return TurnDiffSummary()
|
|
323
|
+
|
|
324
|
+
first_header = re.search(r"(?m)^diff --git ", diff)
|
|
325
|
+
malformed_prelude = (
|
|
326
|
+
first_header is not None
|
|
327
|
+
and bool(diff[: first_header.start()].strip())
|
|
328
|
+
)
|
|
329
|
+
blocks = tuple(_parse_diff_block(lines) for lines in _diff_blocks(diff))
|
|
330
|
+
merged: list[TurnDiffFileStats] = []
|
|
331
|
+
positions: dict[str, int] = {}
|
|
332
|
+
for block in blocks:
|
|
333
|
+
if block.deleted or block.path is None:
|
|
334
|
+
continue
|
|
335
|
+
item = TurnDiffFileStats(
|
|
336
|
+
block.path,
|
|
337
|
+
*(block.counts or (None, None)),
|
|
338
|
+
)
|
|
339
|
+
position = positions.get(item.path)
|
|
340
|
+
if position is None:
|
|
341
|
+
positions[item.path] = len(merged)
|
|
342
|
+
merged.append(item)
|
|
343
|
+
continue
|
|
344
|
+
previous = merged[position]
|
|
345
|
+
if (
|
|
346
|
+
previous.additions is None
|
|
347
|
+
or previous.deletions is None
|
|
348
|
+
or item.additions is None
|
|
349
|
+
or item.deletions is None
|
|
350
|
+
):
|
|
351
|
+
merged[position] = TurnDiffFileStats(item.path)
|
|
352
|
+
else:
|
|
353
|
+
merged[position] = TurnDiffFileStats(
|
|
354
|
+
item.path,
|
|
355
|
+
previous.additions + item.additions,
|
|
356
|
+
previous.deletions + item.deletions,
|
|
357
|
+
)
|
|
358
|
+
if malformed_prelude or any(block.malformed for block in blocks):
|
|
359
|
+
return TurnDiffSummary(
|
|
360
|
+
files=tuple(TurnDiffFileStats(item.path) for item in merged)
|
|
361
|
+
)
|
|
362
|
+
numeric = tuple(block.counts for block in blocks if block.counts is not None)
|
|
363
|
+
totals_known = bool(numeric) and all(
|
|
364
|
+
block.counts is not None or block.binary for block in blocks
|
|
365
|
+
)
|
|
366
|
+
return TurnDiffSummary(
|
|
367
|
+
additions=sum(counts[0] for counts in numeric) if totals_known else None,
|
|
368
|
+
deletions=sum(counts[1] for counts in numeric) if totals_known else None,
|
|
369
|
+
files=tuple(merged),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _diff_blocks(diff: str) -> Iterable[tuple[str, ...]]:
|
|
374
|
+
current: list[str] = []
|
|
375
|
+
for line in diff.splitlines():
|
|
376
|
+
if line.startswith("diff --git "):
|
|
377
|
+
if current:
|
|
378
|
+
yield tuple(current)
|
|
379
|
+
current = [line]
|
|
380
|
+
elif current:
|
|
381
|
+
current.append(line)
|
|
382
|
+
if current:
|
|
383
|
+
yield tuple(current)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _parse_diff_block(lines: Sequence[str]) -> _ParsedDiffBlock:
|
|
387
|
+
header = _git_header_paths(lines[0][len("diff --git ") :])
|
|
388
|
+
header_target = header[1] if header is not None else None
|
|
389
|
+
source_header = False
|
|
390
|
+
target_header = False
|
|
391
|
+
target: str | None = None
|
|
392
|
+
rename_source: str | None = None
|
|
393
|
+
rename_target: str | None = None
|
|
394
|
+
copy_target: str | None = None
|
|
395
|
+
deleted = False
|
|
396
|
+
binary = False
|
|
397
|
+
rename_only = True
|
|
398
|
+
similarity_seen = False
|
|
399
|
+
additions = 0
|
|
400
|
+
deletions = 0
|
|
401
|
+
saw_hunk = False
|
|
402
|
+
malformed = False
|
|
403
|
+
old_expected: int | None = None
|
|
404
|
+
new_expected: int | None = None
|
|
405
|
+
old_seen = 0
|
|
406
|
+
new_seen = 0
|
|
407
|
+
|
|
408
|
+
for line in lines[1:]:
|
|
409
|
+
if line.startswith("@@"):
|
|
410
|
+
if old_expected is not None and (
|
|
411
|
+
old_seen != old_expected or new_seen != new_expected
|
|
412
|
+
):
|
|
413
|
+
malformed = True
|
|
414
|
+
saw_hunk = True
|
|
415
|
+
rename_only = False
|
|
416
|
+
match = _HUNK_HEADER.fullmatch(line)
|
|
417
|
+
if match is None:
|
|
418
|
+
malformed = True
|
|
419
|
+
old_expected = new_expected = None
|
|
420
|
+
continue
|
|
421
|
+
old_expected = int(match.group(1) or "1")
|
|
422
|
+
new_expected = int(match.group(2) or "1")
|
|
423
|
+
old_seen = new_seen = 0
|
|
424
|
+
continue
|
|
425
|
+
if old_expected is not None:
|
|
426
|
+
if line.startswith("+"):
|
|
427
|
+
additions += 1
|
|
428
|
+
new_seen += 1
|
|
429
|
+
elif line.startswith("-"):
|
|
430
|
+
deletions += 1
|
|
431
|
+
old_seen += 1
|
|
432
|
+
elif line.startswith(" "):
|
|
433
|
+
old_seen += 1
|
|
434
|
+
new_seen += 1
|
|
435
|
+
elif line != r"":
|
|
436
|
+
malformed = True
|
|
437
|
+
if old_seen > old_expected or new_seen > new_expected:
|
|
438
|
+
malformed = True
|
|
439
|
+
continue
|
|
440
|
+
if line.startswith("--- "):
|
|
441
|
+
parsed = _metadata_path(line[4:])
|
|
442
|
+
malformed = malformed or source_header or parsed is None
|
|
443
|
+
source_header = True
|
|
444
|
+
rename_only = False
|
|
445
|
+
elif line.startswith("+++ "):
|
|
446
|
+
parsed = _metadata_path(line[4:])
|
|
447
|
+
malformed = malformed or target_header or parsed is None
|
|
448
|
+
target_header = True
|
|
449
|
+
rename_only = False
|
|
450
|
+
if parsed == "/dev/null":
|
|
451
|
+
deleted = True
|
|
452
|
+
elif parsed is not None:
|
|
453
|
+
target = parsed
|
|
454
|
+
elif line.startswith("rename from "):
|
|
455
|
+
parsed = _metadata_path(line[len("rename from ") :])
|
|
456
|
+
if rename_source is not None or parsed is None:
|
|
457
|
+
rename_only = False
|
|
458
|
+
else:
|
|
459
|
+
rename_source = parsed
|
|
460
|
+
elif line.startswith("rename to "):
|
|
461
|
+
parsed = _metadata_path(line[len("rename to ") :])
|
|
462
|
+
if rename_target is not None or parsed is None:
|
|
463
|
+
rename_only = False
|
|
464
|
+
else:
|
|
465
|
+
rename_target = parsed
|
|
466
|
+
elif line == "similarity index 100%" and not similarity_seen:
|
|
467
|
+
similarity_seen = True
|
|
468
|
+
elif line.startswith("copy to "):
|
|
469
|
+
copy_target = _metadata_path(line[len("copy to ") :])
|
|
470
|
+
rename_only = False
|
|
471
|
+
elif line.startswith("deleted file mode "):
|
|
472
|
+
deleted = True
|
|
473
|
+
rename_only = False
|
|
474
|
+
elif line.startswith("Binary files ") and line.endswith(" differ"):
|
|
475
|
+
binary = True
|
|
476
|
+
rename_only = False
|
|
477
|
+
deleted = deleted or line.endswith(" and /dev/null differ")
|
|
478
|
+
elif line == "GIT binary patch":
|
|
479
|
+
binary = True
|
|
480
|
+
rename_only = False
|
|
481
|
+
elif (
|
|
482
|
+
line.startswith(("+", "-", " "))
|
|
483
|
+
or line == r""
|
|
484
|
+
):
|
|
485
|
+
malformed = True
|
|
486
|
+
rename_only = False
|
|
487
|
+
elif line:
|
|
488
|
+
rename_only = False
|
|
489
|
+
|
|
490
|
+
if old_expected is not None and (
|
|
491
|
+
old_seen != old_expected or new_seen != new_expected
|
|
492
|
+
):
|
|
493
|
+
malformed = True
|
|
494
|
+
if (source_header or target_header) and not saw_hunk:
|
|
495
|
+
malformed = True
|
|
496
|
+
if saw_hunk and (
|
|
497
|
+
binary
|
|
498
|
+
or not source_header
|
|
499
|
+
or not target_header
|
|
500
|
+
or (not deleted and target is None)
|
|
501
|
+
):
|
|
502
|
+
malformed = True
|
|
503
|
+
if saw_hunk and target is not None:
|
|
504
|
+
path = _normalize_diff_path(target)
|
|
505
|
+
elif rename_target is not None:
|
|
506
|
+
path = None if rename_target == "/dev/null" else rename_target
|
|
507
|
+
elif copy_target is not None:
|
|
508
|
+
path = None if copy_target == "/dev/null" else copy_target
|
|
509
|
+
else:
|
|
510
|
+
path = _normalize_diff_path(header_target)
|
|
511
|
+
pure_rename = (
|
|
512
|
+
rename_only
|
|
513
|
+
and similarity_seen
|
|
514
|
+
and rename_source is not None
|
|
515
|
+
and rename_target is not None
|
|
516
|
+
)
|
|
517
|
+
counts = (
|
|
518
|
+
(additions, deletions)
|
|
519
|
+
if not malformed and (saw_hunk or pure_rename)
|
|
520
|
+
else None
|
|
521
|
+
)
|
|
522
|
+
return _ParsedDiffBlock(path, deleted, binary, counts, malformed)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def paginate_turn_files(
|
|
526
|
+
files: Sequence[TurnFile],
|
|
527
|
+
page: int,
|
|
528
|
+
*,
|
|
529
|
+
page_size: int = TURN_FILE_PAGE_SIZE,
|
|
530
|
+
) -> TurnFilePage:
|
|
531
|
+
if isinstance(page, bool) or not isinstance(page, int) or page < 0:
|
|
532
|
+
raise TurnFileError("本轮文件页码无效,请重新打开原卡片。")
|
|
533
|
+
if (
|
|
534
|
+
isinstance(page_size, bool)
|
|
535
|
+
or not isinstance(page_size, int)
|
|
536
|
+
or page_size < 1
|
|
537
|
+
):
|
|
538
|
+
raise ValueError("page_size must be a positive integer")
|
|
539
|
+
total_items = len(files)
|
|
540
|
+
total_pages = max(1, (total_items + page_size - 1) // page_size)
|
|
541
|
+
if page >= total_pages:
|
|
542
|
+
raise TurnFileError("本轮文件页码已过期,请重新打开原卡片。")
|
|
543
|
+
start = page * page_size
|
|
544
|
+
return TurnFilePage(
|
|
545
|
+
items=tuple(files[start : start + page_size]),
|
|
546
|
+
page=page,
|
|
547
|
+
total_pages=total_pages,
|
|
548
|
+
total_items=total_items,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def inspect_turn_file_path(path: str, display_path: str) -> TurnFile:
|
|
553
|
+
"""Inspect the current file named by a v4 card manifest entry.
|
|
554
|
+
|
|
555
|
+
The absolute path is the callback capability selected for this product
|
|
556
|
+
boundary. It is intentionally not constrained to a registered Project.
|
|
557
|
+
Missing, inaccessible, directory, and special-file targets remain visible
|
|
558
|
+
during pagination but are marked unavailable and cannot be sent.
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
candidate = Path(path)
|
|
562
|
+
if not candidate.is_absolute():
|
|
563
|
+
return TurnFile(display_path, candidate, None, None)
|
|
564
|
+
try:
|
|
565
|
+
resolved = candidate.resolve(strict=True)
|
|
566
|
+
metadata = resolved.stat()
|
|
567
|
+
except (OSError, RuntimeError, ValueError):
|
|
568
|
+
return TurnFile(display_path, candidate, None, None)
|
|
569
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
570
|
+
return TurnFile(display_path, resolved, None, None)
|
|
571
|
+
return TurnFile(
|
|
572
|
+
display_path=display_path,
|
|
573
|
+
resolved_path=resolved,
|
|
574
|
+
size=metadata.st_size,
|
|
575
|
+
media_kind="image" if _is_supported_image(resolved) else "file",
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def require_turn_file_path(path: str, display_path: str = "") -> TurnFile:
|
|
580
|
+
turn_file = inspect_turn_file_path(path, display_path or Path(path).name)
|
|
581
|
+
if not turn_file.available:
|
|
582
|
+
raise TurnFileError(
|
|
583
|
+
"该文件当前已不可用:路径不存在、不可访问或不是普通文件,未发送。"
|
|
584
|
+
)
|
|
585
|
+
return turn_file
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _reported_paths(
|
|
589
|
+
items: Sequence[object],
|
|
590
|
+
*,
|
|
591
|
+
turn_diff: str | None = None,
|
|
592
|
+
diff_summary: TurnDiffSummary | None = None,
|
|
593
|
+
) -> Iterable[_ReportedTurnPath]:
|
|
594
|
+
summary = diff_summary if diff_summary is not None else turn_diff_summary(turn_diff)
|
|
595
|
+
for file_stats in summary.files:
|
|
596
|
+
yield _ReportedTurnPath(
|
|
597
|
+
"turnDiff",
|
|
598
|
+
file_stats.path,
|
|
599
|
+
file_stats.additions,
|
|
600
|
+
file_stats.deletions,
|
|
601
|
+
deleted=file_stats.deleted,
|
|
602
|
+
)
|
|
603
|
+
for item in items:
|
|
604
|
+
root = getattr(item, "root", item)
|
|
605
|
+
item_type = getattr(root, "type", None)
|
|
606
|
+
status = _enum_value(getattr(root, "status", None))
|
|
607
|
+
if item_type == "fileChange" and status == "completed":
|
|
608
|
+
for change in getattr(root, "changes", ()):
|
|
609
|
+
kind = getattr(getattr(change, "kind", None), "root", None)
|
|
610
|
+
change_type = getattr(kind, "type", None)
|
|
611
|
+
if change_type not in {"add", "update", "delete"}:
|
|
612
|
+
continue
|
|
613
|
+
raw_path = getattr(change, "path", None)
|
|
614
|
+
if change_type == "update":
|
|
615
|
+
move_path = getattr(kind, "move_path", None)
|
|
616
|
+
if isinstance(move_path, str) and move_path:
|
|
617
|
+
raw_path = move_path
|
|
618
|
+
if isinstance(raw_path, str) and raw_path:
|
|
619
|
+
yield _ReportedTurnPath(
|
|
620
|
+
"fileChange", raw_path, deleted=change_type == "delete",
|
|
621
|
+
)
|
|
622
|
+
elif item_type == "imageGeneration" and status == "completed":
|
|
623
|
+
saved_path = _path_value(getattr(root, "saved_path", None))
|
|
624
|
+
if saved_path is not None:
|
|
625
|
+
yield _ReportedTurnPath("imageGeneration", saved_path)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _resolve_turn_file(
|
|
629
|
+
reported: _ReportedTurnPath,
|
|
630
|
+
root: Path,
|
|
631
|
+
home: Path | None,
|
|
632
|
+
) -> TurnFile | None:
|
|
633
|
+
try:
|
|
634
|
+
candidate = Path(reported.value)
|
|
635
|
+
if not candidate.is_absolute():
|
|
636
|
+
candidate = root / candidate
|
|
637
|
+
resolved = candidate.resolve(strict=True)
|
|
638
|
+
metadata = resolved.stat()
|
|
639
|
+
except FileNotFoundError:
|
|
640
|
+
if not reported.deleted:
|
|
641
|
+
return None
|
|
642
|
+
try:
|
|
643
|
+
resolved = candidate.resolve()
|
|
644
|
+
except (OSError, RuntimeError, ValueError):
|
|
645
|
+
return None
|
|
646
|
+
return TurnFile(
|
|
647
|
+
display_path=_display_path(reported, resolved, project_root=root, home=home),
|
|
648
|
+
resolved_path=resolved,
|
|
649
|
+
size=None,
|
|
650
|
+
media_kind=None,
|
|
651
|
+
additions=reported.additions,
|
|
652
|
+
deletions=reported.deletions,
|
|
653
|
+
deleted=True,
|
|
654
|
+
)
|
|
655
|
+
except (OSError, RuntimeError, ValueError):
|
|
656
|
+
return None
|
|
657
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
658
|
+
return None
|
|
659
|
+
display_path = _display_path(
|
|
660
|
+
reported,
|
|
661
|
+
resolved,
|
|
662
|
+
project_root=root,
|
|
663
|
+
home=home,
|
|
664
|
+
)
|
|
665
|
+
media_kind: Literal["image", "file"] = (
|
|
666
|
+
"image" if _is_supported_image(resolved) else "file"
|
|
667
|
+
)
|
|
668
|
+
return TurnFile(
|
|
669
|
+
display_path=display_path,
|
|
670
|
+
resolved_path=resolved,
|
|
671
|
+
size=metadata.st_size,
|
|
672
|
+
media_kind=media_kind,
|
|
673
|
+
additions=(reported.additions if media_kind == "file" else None),
|
|
674
|
+
deletions=(reported.deletions if media_kind == "file" else None),
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
def _display_path(
|
|
678
|
+
reported: _ReportedTurnPath,
|
|
679
|
+
resolved: Path,
|
|
680
|
+
*,
|
|
681
|
+
project_root: Path,
|
|
682
|
+
home: Path | None,
|
|
683
|
+
) -> str:
|
|
684
|
+
if resolved.is_relative_to(project_root):
|
|
685
|
+
return resolved.relative_to(project_root).as_posix()
|
|
686
|
+
if reported.item_type == "imageGeneration":
|
|
687
|
+
return f"生成图片/{resolved.name}"
|
|
688
|
+
if home is not None and resolved.is_relative_to(home):
|
|
689
|
+
return f"~/{resolved.relative_to(home).as_posix()}"
|
|
690
|
+
parts = tuple(part for part in resolved.parts if part != resolved.anchor)
|
|
691
|
+
visible = parts[-3:]
|
|
692
|
+
prefix = "外部文件/…/" if len(parts) > len(visible) else "外部文件/"
|
|
693
|
+
return prefix + "/".join(visible)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _enum_value(value: object) -> object:
|
|
697
|
+
return getattr(value, "value", value)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _path_value(value: object) -> str | None:
|
|
701
|
+
if isinstance(value, str) and value:
|
|
702
|
+
return value
|
|
703
|
+
root = getattr(value, "root", None)
|
|
704
|
+
return root if isinstance(root, str) and root else None
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _is_supported_image(path: Path) -> bool:
|
|
708
|
+
try:
|
|
709
|
+
with path.open("rb") as stream:
|
|
710
|
+
header = stream.read(12)
|
|
711
|
+
except OSError:
|
|
712
|
+
return False
|
|
713
|
+
return is_supported_image_header(header)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def is_supported_image_header(header: bytes) -> bool:
|
|
717
|
+
"""Use the same PNG/JPEG/GIF/WebP signature for file sending and previews."""
|
|
718
|
+
return (
|
|
719
|
+
header.startswith(b"\x89PNG\r\n\x1a\n")
|
|
720
|
+
or header.startswith(b"\xff\xd8\xff")
|
|
721
|
+
or header.startswith((b"GIF87a", b"GIF89a"))
|
|
722
|
+
or (
|
|
723
|
+
len(header) >= 12
|
|
724
|
+
and header[:4] == b"RIFF"
|
|
725
|
+
and header[8:12] == b"WEBP"
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _git_header_paths(value: str) -> tuple[str, str] | None:
|
|
731
|
+
tokens: list[str] = []
|
|
732
|
+
index = 0
|
|
733
|
+
while index < len(value):
|
|
734
|
+
while index < len(value) and value[index].isspace():
|
|
735
|
+
index += 1
|
|
736
|
+
if index >= len(value):
|
|
737
|
+
break
|
|
738
|
+
start = index
|
|
739
|
+
if value[index] == '"':
|
|
740
|
+
index += 1
|
|
741
|
+
escaped = False
|
|
742
|
+
while index < len(value):
|
|
743
|
+
character = value[index]
|
|
744
|
+
index += 1
|
|
745
|
+
if escaped:
|
|
746
|
+
escaped = False
|
|
747
|
+
elif character == "\\":
|
|
748
|
+
escaped = True
|
|
749
|
+
elif character == '"':
|
|
750
|
+
break
|
|
751
|
+
else:
|
|
752
|
+
return None
|
|
753
|
+
else:
|
|
754
|
+
while index < len(value) and not value[index].isspace():
|
|
755
|
+
index += 1
|
|
756
|
+
token = _decode_git_path(value[start:index])
|
|
757
|
+
if token is None:
|
|
758
|
+
return None
|
|
759
|
+
tokens.append(token)
|
|
760
|
+
if len(tokens) == 2:
|
|
761
|
+
return tokens[0], tokens[1]
|
|
762
|
+
same_path = re.fullmatch(r"a/(.+) b/\1", value)
|
|
763
|
+
if same_path is None:
|
|
764
|
+
return None
|
|
765
|
+
path = same_path.group(1)
|
|
766
|
+
return f"a/{path}", f"b/{path}"
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _metadata_path(value: str) -> str | None:
|
|
770
|
+
value = value.strip()
|
|
771
|
+
if not value:
|
|
772
|
+
return None
|
|
773
|
+
if value.startswith('"'):
|
|
774
|
+
index = 1
|
|
775
|
+
escaped = False
|
|
776
|
+
while index < len(value):
|
|
777
|
+
character = value[index]
|
|
778
|
+
index += 1
|
|
779
|
+
if escaped:
|
|
780
|
+
escaped = False
|
|
781
|
+
elif character == "\\":
|
|
782
|
+
escaped = True
|
|
783
|
+
elif character == '"':
|
|
784
|
+
return _decode_git_path(value[:index])
|
|
785
|
+
return None
|
|
786
|
+
# Traditional unified diffs may append a timestamp after a tab.
|
|
787
|
+
return value.split("\t", 1)[0]
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _decode_git_path(value: str) -> str | None:
|
|
791
|
+
if not value:
|
|
792
|
+
return None
|
|
793
|
+
if not value.startswith('"'):
|
|
794
|
+
return value
|
|
795
|
+
try:
|
|
796
|
+
decoded = ast.literal_eval(value)
|
|
797
|
+
except (SyntaxError, ValueError):
|
|
798
|
+
return None
|
|
799
|
+
if not isinstance(decoded, str):
|
|
800
|
+
return None
|
|
801
|
+
try:
|
|
802
|
+
return decoded.encode("latin-1").decode("utf-8")
|
|
803
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
804
|
+
return decoded
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _normalize_diff_path(value: str | None) -> str | None:
|
|
808
|
+
if not isinstance(value, str) or not value or value == "/dev/null":
|
|
809
|
+
return None
|
|
810
|
+
if value.startswith(("a/", "b/")):
|
|
811
|
+
value = value[2:]
|
|
812
|
+
return value or None
|