python-codex 0.3.3__py3-none-any.whl → 0.4.1__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.
- pycodex/__init__.py +3 -27
- pycodex/bootstrap.py +5 -45
- pycodex/cli.py +1 -14
- pycodex/context.py +1 -1
- pycodex/events.py +1 -1
- pycodex/model.py +2 -2
- pycodex/portable/__init__.py +45 -0
- pycodex/{portable.py → portable/client.py} +4 -7
- pycodex/{portable_server.py → portable/server.py} +3 -3
- pycodex/protocol.py +1 -1
- pycodex/runtime.py +2 -2
- pycodex/tools/base_tool.py +1 -1
- pycodex/tools/close_agent_tool.py +1 -1
- pycodex/tools/code_mode_manager.py +1 -1
- pycodex/tools/request_permissions_tool.py +1 -1
- pycodex/tools/resume_agent_tool.py +1 -1
- pycodex/{runtime_services.py → tools/runtime_services.py} +5 -5
- pycodex/tools/send_input_tool.py +1 -1
- pycodex/tools/spawn_agent_tool.py +1 -1
- pycodex/tools/unified_exec_manager.py +1 -1
- pycodex/tools/update_plan_tool.py +1 -1
- pycodex/tools/view_image_tool.py +5 -0
- pycodex/tools/wait_agent_tool.py +1 -1
- pycodex/utils/__init__.py +0 -4
- pycodex/utils/_aes_gcm/LICENSE +56 -0
- pycodex/utils/_aes_gcm/README.md +61 -0
- pycodex/utils/_aes_gcm/__init__.py +1 -0
- pycodex/utils/_aes_gcm/aes.py +175 -0
- pycodex/utils/_aes_gcm/gcm.py +73 -0
- pycodex/utils/_aes_gcm/source-sha256.json +4 -0
- pycodex/utils/async_bridge.py +3 -4
- pycodex/{compat.py → utils/compat.py} +2 -0
- pycodex/utils/crypto.py +35 -0
- pycodex/utils/debug.py +24 -0
- pycodex/{doctor.py → utils/doctor.py} +3 -3
- pycodex/utils/image_utils.py +3 -2
- pycodex/{model_metadata.py → utils/model_metadata.py} +2 -2
- pycodex/utils/session_persist.py +28 -0
- {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/METADATA +124 -42
- python_codex-0.4.1.dist-info/RECORD +90 -0
- {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/entry_points.txt +0 -1
- responses_server/app.py +28 -15
- pycodex/feishu_card.py +0 -793
- pycodex/feishu_link.py +0 -440
- pycodex/tools/ipython_tool.py +0 -142
- python_codex-0.3.3.dist-info/RECORD +0 -98
- workspace_server/__init__.py +0 -39
- workspace_server/__main__.py +0 -4
- workspace_server/app.py +0 -1906
- workspace_server/content_views.LICENSE.txt +0 -470
- workspace_server/content_views.css +0 -1
- workspace_server/content_views.mjs +0 -25
- workspace_server/editor.css +0 -1
- workspace_server/editor.mjs +0 -1260
- workspace_server/owned_process.py +0 -27
- workspace_server/workspace.html +0 -2267
- workspace_server/workspace_views.py +0 -533
- workspace_server/workspaces.html +0 -550
- workspace_server/workspaces.py +0 -632
- {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/WHEEL +0 -0
- {python_codex-0.3.3.dist-info → python_codex-0.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,533 +0,0 @@
|
|
|
1
|
-
"""Read-only file and diff views opened by workspace links."""
|
|
2
|
-
|
|
3
|
-
import base64
|
|
4
|
-
import io
|
|
5
|
-
import json
|
|
6
|
-
import mimetypes
|
|
7
|
-
import os
|
|
8
|
-
import re
|
|
9
|
-
import stat
|
|
10
|
-
import subprocess
|
|
11
|
-
import tempfile
|
|
12
|
-
import threading
|
|
13
|
-
from copy import deepcopy
|
|
14
|
-
from datetime import datetime, timezone
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from urllib.parse import unquote
|
|
17
|
-
|
|
18
|
-
from PIL import Image
|
|
19
|
-
|
|
20
|
-
from pycodex.utils import uuid7_string
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _view_title(documents):
|
|
24
|
-
return " <-> ".join(document["label"].replace("\\", "/") for document in documents)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class WorkspaceViewStore:
|
|
28
|
-
"""Share file-backed views and fixed Git contents across workspace sessions."""
|
|
29
|
-
|
|
30
|
-
max_storage_bytes = 64 * 1024 * 1024
|
|
31
|
-
max_file_bytes = 2 * 1024 * 1024
|
|
32
|
-
max_text_lines = 20000
|
|
33
|
-
|
|
34
|
-
def __init__(self, board_path=None):
|
|
35
|
-
self.path = (
|
|
36
|
-
None
|
|
37
|
-
if board_path is None
|
|
38
|
-
else Path(board_path).with_suffix(".pycodex-views.json")
|
|
39
|
-
)
|
|
40
|
-
self._lock = threading.Lock()
|
|
41
|
-
self._views = []
|
|
42
|
-
self._file_states = {}
|
|
43
|
-
self._reveal_revision = 0
|
|
44
|
-
if self.path is not None:
|
|
45
|
-
try:
|
|
46
|
-
with self.path.open("rb") as handle:
|
|
47
|
-
data = handle.read(self.max_storage_bytes + 1)
|
|
48
|
-
except FileNotFoundError:
|
|
49
|
-
return
|
|
50
|
-
if len(data) > self.max_storage_bytes:
|
|
51
|
-
raise ValueError("workspace view storage exceeds 64 MiB")
|
|
52
|
-
payload = json.loads(data.decode("utf-8"))
|
|
53
|
-
if payload["version"] != 1 or not isinstance(payload["views"], list):
|
|
54
|
-
raise ValueError("invalid workspace view storage")
|
|
55
|
-
self._views = payload["views"]
|
|
56
|
-
self._reveal_revision = payload.get("reveal_revision", 0)
|
|
57
|
-
for view in self._views:
|
|
58
|
-
# Earlier snapshots have no recorded source and remain fixed.
|
|
59
|
-
view.setdefault("revision", 1)
|
|
60
|
-
view.setdefault("reveal_revision", 0)
|
|
61
|
-
view["title"] = _view_title(view["documents"])
|
|
62
|
-
|
|
63
|
-
def list_views(self):
|
|
64
|
-
with self._lock:
|
|
65
|
-
self._refresh()
|
|
66
|
-
return [
|
|
67
|
-
dict(
|
|
68
|
-
{key: view[key] for key in (
|
|
69
|
-
"id", "kind", "title", "created_at", "revision", "reveal_revision"
|
|
70
|
-
)},
|
|
71
|
-
live=any(document.get("source") for document in view["documents"]),
|
|
72
|
-
selection=deepcopy(view.get("selection")),
|
|
73
|
-
)
|
|
74
|
-
for view in self._views
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
def get(self, view_id):
|
|
78
|
-
with self._lock:
|
|
79
|
-
self._find(view_id)
|
|
80
|
-
self._refresh()
|
|
81
|
-
return deepcopy(self._find(view_id))
|
|
82
|
-
|
|
83
|
-
def add(self, snapshot, selection=None):
|
|
84
|
-
with self._lock:
|
|
85
|
-
existing = self._matching(snapshot)
|
|
86
|
-
if existing is not None:
|
|
87
|
-
return self._reveal(existing, False, selection)
|
|
88
|
-
view = dict(
|
|
89
|
-
deepcopy(snapshot),
|
|
90
|
-
title=_view_title(snapshot["documents"]),
|
|
91
|
-
id=uuid7_string(),
|
|
92
|
-
created_at=datetime.now(timezone.utc).isoformat(),
|
|
93
|
-
revision=1,
|
|
94
|
-
)
|
|
95
|
-
return self._reveal(view, True, selection)
|
|
96
|
-
|
|
97
|
-
def select(self, reference, selection=None):
|
|
98
|
-
with self._lock:
|
|
99
|
-
existing = self._matching(reference)
|
|
100
|
-
if existing is not None:
|
|
101
|
-
return self._reveal(existing, False, selection)
|
|
102
|
-
return None
|
|
103
|
-
|
|
104
|
-
def _identity(self, view):
|
|
105
|
-
references = []
|
|
106
|
-
for document in view["documents"]:
|
|
107
|
-
if document.get("source") is not None:
|
|
108
|
-
source = document["source"]
|
|
109
|
-
references.append((
|
|
110
|
-
"file", os.path.normcase(os.path.abspath(source["path"])),
|
|
111
|
-
source["working_tree"],
|
|
112
|
-
))
|
|
113
|
-
elif document.get("git_source") is not None:
|
|
114
|
-
source = document["git_source"]
|
|
115
|
-
references.append((
|
|
116
|
-
"git", os.path.normcase(source["repository"]),
|
|
117
|
-
source["revision"], source["path"],
|
|
118
|
-
))
|
|
119
|
-
else:
|
|
120
|
-
return None
|
|
121
|
-
return view["kind"], references
|
|
122
|
-
|
|
123
|
-
def _matching(self, reference):
|
|
124
|
-
identity = self._identity(reference)
|
|
125
|
-
if identity is not None:
|
|
126
|
-
for view in self._views:
|
|
127
|
-
if self._identity(view) == identity:
|
|
128
|
-
return view
|
|
129
|
-
return None
|
|
130
|
-
|
|
131
|
-
def _reveal(self, view, created, selection):
|
|
132
|
-
revision = self._reveal_revision + 1
|
|
133
|
-
view = dict(view, reveal_revision=revision, selection=deepcopy(selection))
|
|
134
|
-
views = self._views + [view] if created else [
|
|
135
|
-
view if item["id"] == view["id"] else item for item in self._views
|
|
136
|
-
]
|
|
137
|
-
self._save(views, revision)
|
|
138
|
-
self._views = views
|
|
139
|
-
self._reveal_revision = revision
|
|
140
|
-
return deepcopy(view)
|
|
141
|
-
|
|
142
|
-
def close(self, view_id):
|
|
143
|
-
with self._lock:
|
|
144
|
-
self._find(view_id)
|
|
145
|
-
views = [view for view in self._views if view["id"] != view_id]
|
|
146
|
-
self._save(views)
|
|
147
|
-
self._views = views
|
|
148
|
-
self._file_states = {
|
|
149
|
-
key: value for key, value in self._file_states.items()
|
|
150
|
-
if key[0] != view_id
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
def _find(self, view_id):
|
|
154
|
-
for view in self._views:
|
|
155
|
-
if view["id"] == view_id:
|
|
156
|
-
return view
|
|
157
|
-
raise KeyError(view_id)
|
|
158
|
-
|
|
159
|
-
def _refresh(self):
|
|
160
|
-
views = []
|
|
161
|
-
file_states = {}
|
|
162
|
-
changed = False
|
|
163
|
-
for view in self._views:
|
|
164
|
-
documents = []
|
|
165
|
-
for index, document in enumerate(view["documents"]):
|
|
166
|
-
source = document.get("source")
|
|
167
|
-
if source is None:
|
|
168
|
-
documents.append(document)
|
|
169
|
-
continue
|
|
170
|
-
key = (view["id"], index)
|
|
171
|
-
state = None
|
|
172
|
-
current = document
|
|
173
|
-
try:
|
|
174
|
-
path = Path(source["path"])
|
|
175
|
-
info = path.lstat() if source["working_tree"] else path.stat()
|
|
176
|
-
state = (
|
|
177
|
-
info.st_dev, info.st_ino, info.st_size,
|
|
178
|
-
info.st_mtime_ns, info.st_ctime_ns,
|
|
179
|
-
)
|
|
180
|
-
if self._file_states.get(key) != state:
|
|
181
|
-
current = self.read_document(
|
|
182
|
-
path, document["label"], view["kind"] == "file",
|
|
183
|
-
source["working_tree"],
|
|
184
|
-
)
|
|
185
|
-
except FileNotFoundError:
|
|
186
|
-
if view["kind"] == "diff":
|
|
187
|
-
current = dict(
|
|
188
|
-
label=document["label"], text="", missing=True,
|
|
189
|
-
source=source,
|
|
190
|
-
)
|
|
191
|
-
else:
|
|
192
|
-
current = dict(
|
|
193
|
-
document, missing=True,
|
|
194
|
-
error="File no longer exists: " + document["label"],
|
|
195
|
-
)
|
|
196
|
-
except (OSError, ValueError, Image.DecompressionBombError) as error:
|
|
197
|
-
if isinstance(error, OSError):
|
|
198
|
-
state = None
|
|
199
|
-
current = dict(document, error=str(error))
|
|
200
|
-
file_states[key] = state
|
|
201
|
-
documents.append(current)
|
|
202
|
-
if documents != view["documents"]:
|
|
203
|
-
view = dict(view, documents=documents, revision=view["revision"] + 1)
|
|
204
|
-
changed = True
|
|
205
|
-
views.append(view)
|
|
206
|
-
if changed:
|
|
207
|
-
self._save(views)
|
|
208
|
-
self._views = views
|
|
209
|
-
self._file_states = file_states
|
|
210
|
-
|
|
211
|
-
def read_document(
|
|
212
|
-
self, path, label, allow_image=False, working_tree=False, allow_missing=False
|
|
213
|
-
):
|
|
214
|
-
path = Path(path)
|
|
215
|
-
source = {
|
|
216
|
-
"path": os.path.abspath(str(path)),
|
|
217
|
-
"working_tree": working_tree,
|
|
218
|
-
}
|
|
219
|
-
try:
|
|
220
|
-
if working_tree and path.is_symlink():
|
|
221
|
-
data = os.readlink(str(path)).encode("utf-8")
|
|
222
|
-
else:
|
|
223
|
-
if not stat.S_ISREG(path.stat().st_mode):
|
|
224
|
-
raise ValueError(
|
|
225
|
-
"only regular files can be displayed: {0}".format(label)
|
|
226
|
-
)
|
|
227
|
-
with path.open("rb") as handle:
|
|
228
|
-
data = handle.read(self.max_file_bytes + 1)
|
|
229
|
-
except FileNotFoundError:
|
|
230
|
-
if not allow_missing:
|
|
231
|
-
raise
|
|
232
|
-
return {"label": label, "text": "", "missing": True, "source": source}
|
|
233
|
-
document = self.decode_document(data, label, allow_image)
|
|
234
|
-
document["source"] = source
|
|
235
|
-
return document
|
|
236
|
-
|
|
237
|
-
def decode_document(self, data, label, allow_image=False):
|
|
238
|
-
if len(data) > self.max_file_bytes:
|
|
239
|
-
raise ValueError("file exceeds 2 MiB: {0}".format(label))
|
|
240
|
-
media_type = mimetypes.guess_type(label)[0] or ""
|
|
241
|
-
if (
|
|
242
|
-
allow_image
|
|
243
|
-
and media_type.startswith("image/")
|
|
244
|
-
and media_type != "image/svg+xml"
|
|
245
|
-
):
|
|
246
|
-
with Image.open(io.BytesIO(data)) as image:
|
|
247
|
-
image_type = {
|
|
248
|
-
"PNG": "image/png",
|
|
249
|
-
"JPEG": "image/jpeg",
|
|
250
|
-
"GIF": "image/gif",
|
|
251
|
-
"WEBP": "image/webp",
|
|
252
|
-
}.get(image.format)
|
|
253
|
-
if image_type is None:
|
|
254
|
-
raise ValueError("unsupported image format: {0}".format(label))
|
|
255
|
-
if image.width * image.height > 20000000:
|
|
256
|
-
raise ValueError("image exceeds 20 megapixels: {0}".format(label))
|
|
257
|
-
image.verify()
|
|
258
|
-
return {
|
|
259
|
-
"label": label,
|
|
260
|
-
"image_url": "data:{0};base64,{1}".format(
|
|
261
|
-
image_type, base64.b64encode(data).decode("ascii")
|
|
262
|
-
),
|
|
263
|
-
}
|
|
264
|
-
if b"\0" in data:
|
|
265
|
-
raise ValueError(
|
|
266
|
-
"binary files cannot be displayed as text: {0}".format(label)
|
|
267
|
-
)
|
|
268
|
-
text = data.decode("utf-8", errors="replace")
|
|
269
|
-
if len(text.splitlines()) > self.max_text_lines:
|
|
270
|
-
raise ValueError("text exceeds 20,000 lines: {0}".format(label))
|
|
271
|
-
return {"label": label, "text": text}
|
|
272
|
-
|
|
273
|
-
def _save(self, views, reveal_revision=None):
|
|
274
|
-
data = json.dumps({
|
|
275
|
-
"version": 1,
|
|
276
|
-
"views": views,
|
|
277
|
-
"reveal_revision": (
|
|
278
|
-
self._reveal_revision if reveal_revision is None else reveal_revision
|
|
279
|
-
),
|
|
280
|
-
}, ensure_ascii=False).encode("utf-8")
|
|
281
|
-
if len(data) > self.max_storage_bytes:
|
|
282
|
-
raise ValueError("workspace views exceed 64 MiB; close some tabs")
|
|
283
|
-
if self.path is None:
|
|
284
|
-
return
|
|
285
|
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
-
with tempfile.NamedTemporaryFile(
|
|
287
|
-
dir=str(self.path.parent), prefix=self.path.name + ".", delete=False
|
|
288
|
-
) as handle:
|
|
289
|
-
temporary_path = Path(handle.name)
|
|
290
|
-
try:
|
|
291
|
-
handle.write(data)
|
|
292
|
-
handle.close()
|
|
293
|
-
os.replace(str(temporary_path), str(self.path))
|
|
294
|
-
finally:
|
|
295
|
-
if temporary_path.exists():
|
|
296
|
-
temporary_path.unlink()
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
class WorkspaceViewOpener:
|
|
300
|
-
"""Resolve a clicked file path or workspace URI without executing commands."""
|
|
301
|
-
|
|
302
|
-
def __init__(self, views, cwd):
|
|
303
|
-
self._views = views
|
|
304
|
-
self._cwd = Path(cwd).resolve()
|
|
305
|
-
|
|
306
|
-
def open(self, href):
|
|
307
|
-
action, options = self._parse_link(href)
|
|
308
|
-
line = options.get("line")
|
|
309
|
-
if line is not None:
|
|
310
|
-
if not re.fullmatch(r"[1-9][0-9]*", line):
|
|
311
|
-
raise ValueError("line must be a positive integer")
|
|
312
|
-
line = int(line)
|
|
313
|
-
side = options.get("side", "right")
|
|
314
|
-
if action == "file":
|
|
315
|
-
kind = "file"
|
|
316
|
-
references = [self._file_reference(options["path"])]
|
|
317
|
-
elif action == "git-file":
|
|
318
|
-
kind = "file"
|
|
319
|
-
references = [self._git_object_reference(options["target"])]
|
|
320
|
-
elif action == "diff":
|
|
321
|
-
kind = "diff"
|
|
322
|
-
references = [
|
|
323
|
-
self._file_reference(options[name]) for name in ("left", "right")
|
|
324
|
-
]
|
|
325
|
-
else:
|
|
326
|
-
kind = "diff"
|
|
327
|
-
left = self._git_object_reference(options["left"])
|
|
328
|
-
if "right" in options:
|
|
329
|
-
right = self._git_object_reference(options["right"])
|
|
330
|
-
else:
|
|
331
|
-
source = left["git_source"]
|
|
332
|
-
right = self._file_reference(
|
|
333
|
-
Path(source["repository"]) / source["path"],
|
|
334
|
-
source["path"], working_tree=True,
|
|
335
|
-
)
|
|
336
|
-
references = [left, right]
|
|
337
|
-
selection = None if line is None else {
|
|
338
|
-
"document": 0 if kind == "file" or side == "left" else 1,
|
|
339
|
-
"line": line,
|
|
340
|
-
}
|
|
341
|
-
allow_missing = action == "git-diff"
|
|
342
|
-
selected_document = None
|
|
343
|
-
if selection is not None:
|
|
344
|
-
selected_document = self._read_reference(
|
|
345
|
-
references[selection["document"]], kind == "file", allow_missing
|
|
346
|
-
)
|
|
347
|
-
if "text" not in selected_document or selected_document.get("missing"):
|
|
348
|
-
raise ValueError("line selection requires an existing text file")
|
|
349
|
-
text = selected_document["text"].replace("\r\n", "\n").replace("\r", "\n")
|
|
350
|
-
if selection["line"] > text.count("\n") + 1:
|
|
351
|
-
raise ValueError("line {0} is outside the file".format(selection["line"]))
|
|
352
|
-
existing = self._views.select(
|
|
353
|
-
{"kind": kind, "documents": references}, selection
|
|
354
|
-
)
|
|
355
|
-
if existing is not None:
|
|
356
|
-
return self._views.get(existing["id"])
|
|
357
|
-
documents = [
|
|
358
|
-
selected_document if selection is not None and selection["document"] == index
|
|
359
|
-
else self._read_reference(reference, kind == "file", allow_missing)
|
|
360
|
-
for index, reference in enumerate(references)
|
|
361
|
-
]
|
|
362
|
-
if kind == "diff" and all(document.get("missing") for document in documents):
|
|
363
|
-
raise ValueError("file is absent on both sides of the comparison")
|
|
364
|
-
view = self._views.add({"kind": kind, "documents": documents}, selection)
|
|
365
|
-
return self._views.get(view["id"])
|
|
366
|
-
|
|
367
|
-
def _parse_link(self, href):
|
|
368
|
-
if not isinstance(href, str) or not href or len(href) > 8192:
|
|
369
|
-
raise ValueError("expected a file path or workspace URI (at most 8192 characters)")
|
|
370
|
-
if href != href.strip() or any(
|
|
371
|
-
ord(character) < 32 or ord(character) == 127 for character in href
|
|
372
|
-
):
|
|
373
|
-
raise ValueError("surrounding whitespace and control characters are not allowed in workspace links")
|
|
374
|
-
if re.search(r"%(?![0-9a-fA-F]{2})", href):
|
|
375
|
-
raise ValueError("invalid percent encoding in workspace link")
|
|
376
|
-
destination, separator, fragment = href.partition("#")
|
|
377
|
-
if "?" in destination:
|
|
378
|
-
raise ValueError("workspace links do not use query parameters; encode a literal ? as %3F")
|
|
379
|
-
action, options = self._parse_destination(destination)
|
|
380
|
-
if separator:
|
|
381
|
-
pattern = r"[LR]-?[0-9]+" if action in ("diff", "git-diff") else r"L-?[0-9]+"
|
|
382
|
-
if not re.fullmatch(pattern, fragment, re.IGNORECASE):
|
|
383
|
-
raise ValueError("expected #L<number> or, for a diff's right side, #R<number>")
|
|
384
|
-
if "line" in options:
|
|
385
|
-
raise ValueError("specify the line only once")
|
|
386
|
-
options["line"] = fragment[1:]
|
|
387
|
-
if action in ("diff", "git-diff"):
|
|
388
|
-
options["side"] = "left" if fragment[0].upper() == "L" else "right"
|
|
389
|
-
options = {
|
|
390
|
-
key: unquote(value, encoding="utf-8", errors="strict")
|
|
391
|
-
for key, value in options.items()
|
|
392
|
-
}
|
|
393
|
-
if any(ord(character) < 32 or ord(character) == 127
|
|
394
|
-
for value in options.values() for character in value):
|
|
395
|
-
raise ValueError("control characters are not allowed in workspace links")
|
|
396
|
-
return action, options
|
|
397
|
-
|
|
398
|
-
def _parse_destination(self, destination):
|
|
399
|
-
if destination.lower().startswith("git:"):
|
|
400
|
-
target = destination[4:]
|
|
401
|
-
if not self._is_git_target(target):
|
|
402
|
-
raise ValueError("expected git:REV:path")
|
|
403
|
-
return "git-file", {"target": target}
|
|
404
|
-
if destination.lower().startswith("diff:"):
|
|
405
|
-
expression = destination[5:]
|
|
406
|
-
# Parent-directory components are paths, not comparison separators.
|
|
407
|
-
separator = re.search(r"(?<![/.])\.\.(?!/)", expression)
|
|
408
|
-
if separator is None:
|
|
409
|
-
if not self._is_git_target(expression):
|
|
410
|
-
raise ValueError("expected diff:REV:path, diff:REV..REV:path or diff:left..right")
|
|
411
|
-
return "git-diff", {"left": expression}
|
|
412
|
-
left, right = expression[:separator.start()], expression[separator.end():]
|
|
413
|
-
if not left or not right or re.search(r"(?<![/.])\.\.(?!/)", right):
|
|
414
|
-
raise ValueError("expected two comparison endpoints; encode literal .. as %2E%2E")
|
|
415
|
-
if self._is_git_target(right):
|
|
416
|
-
if not self._is_git_target(left):
|
|
417
|
-
unused_revision, unused_separator, path = right.partition(":")
|
|
418
|
-
left += ":" + path
|
|
419
|
-
return "git-diff", {"left": left, "right": right}
|
|
420
|
-
if self._is_git_target(left) or left.startswith("//") or right.startswith("//"):
|
|
421
|
-
raise ValueError("compare two local files or two Git objects")
|
|
422
|
-
return "diff", {"left": left, "right": right}
|
|
423
|
-
if re.match(
|
|
424
|
-
r"^(?:https?|ftps?|mailto|tel|sms|javascript|vbscript|data|blob|about|file):",
|
|
425
|
-
destination, re.IGNORECASE,
|
|
426
|
-
):
|
|
427
|
-
raise ValueError("expected a local file path or workspace URI")
|
|
428
|
-
path = destination
|
|
429
|
-
options = {}
|
|
430
|
-
name, separator, number = path.rpartition(":")
|
|
431
|
-
if separator and re.fullmatch(r"-?[0-9]+", number):
|
|
432
|
-
path, options["line"] = name, number
|
|
433
|
-
if not path or path.startswith("//") or self._is_git_target(path):
|
|
434
|
-
raise ValueError("expected a local file path or workspace URI")
|
|
435
|
-
options["path"] = path
|
|
436
|
-
return "file", options
|
|
437
|
-
|
|
438
|
-
def _is_git_target(self, value):
|
|
439
|
-
return ":" in value and not re.match(
|
|
440
|
-
r"^[a-zA-Z]:(?:[/\\]|%2f|%5c)", value, re.IGNORECASE
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
def _file_reference(self, name, label=None, working_tree=False):
|
|
444
|
-
return {
|
|
445
|
-
"label": str(name if label is None else label).replace("\\", "/"),
|
|
446
|
-
"source": {
|
|
447
|
-
"path": os.path.abspath(str(self._cwd / name)),
|
|
448
|
-
"working_tree": working_tree,
|
|
449
|
-
},
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
def _git(self, arguments):
|
|
453
|
-
result = subprocess.run(
|
|
454
|
-
["git", "--no-pager", "--literal-pathspecs"] + arguments,
|
|
455
|
-
cwd=str(self._cwd),
|
|
456
|
-
stdout=subprocess.PIPE,
|
|
457
|
-
stderr=subprocess.PIPE,
|
|
458
|
-
timeout=15,
|
|
459
|
-
)
|
|
460
|
-
if result.returncode:
|
|
461
|
-
raise ValueError(
|
|
462
|
-
result.stderr.decode("utf-8", errors="replace").strip()
|
|
463
|
-
or "Git command failed"
|
|
464
|
-
)
|
|
465
|
-
return result.stdout
|
|
466
|
-
|
|
467
|
-
def _read_reference(self, reference, allow_image, allow_missing):
|
|
468
|
-
if "source" in reference:
|
|
469
|
-
source = reference["source"]
|
|
470
|
-
return self._views.read_document(
|
|
471
|
-
source["path"], reference["label"], allow_image,
|
|
472
|
-
source["working_tree"], allow_missing,
|
|
473
|
-
)
|
|
474
|
-
source = reference["git_source"]
|
|
475
|
-
entry = self._git([
|
|
476
|
-
"ls-tree", "--full-tree", "-z", source["revision"], "--", source["path"]
|
|
477
|
-
])
|
|
478
|
-
if not entry:
|
|
479
|
-
if not allow_missing:
|
|
480
|
-
raise ValueError("Git file does not exist: " + reference["label"])
|
|
481
|
-
return dict(reference, text="", missing=True)
|
|
482
|
-
metadata, unused_path = entry.rstrip(b"\0").split(b"\t", 1)
|
|
483
|
-
unused_mode, object_type, object_id = metadata.split()
|
|
484
|
-
if object_type != b"blob":
|
|
485
|
-
raise ValueError("Git path must identify a file: " + source["path"])
|
|
486
|
-
object_id = object_id.decode("ascii")
|
|
487
|
-
size = int(self._git(["cat-file", "-s", object_id]))
|
|
488
|
-
if size > self._views.max_file_bytes:
|
|
489
|
-
raise ValueError("file exceeds 2 MiB: " + reference["label"])
|
|
490
|
-
document = self._views.decode_document(
|
|
491
|
-
self._git(["cat-file", "blob", object_id]), reference["label"], allow_image
|
|
492
|
-
)
|
|
493
|
-
document["git_source"] = source
|
|
494
|
-
return document
|
|
495
|
-
|
|
496
|
-
def _revision(self, revision):
|
|
497
|
-
if not revision or revision.startswith("-") or ":" in revision or "\0" in revision:
|
|
498
|
-
raise ValueError("expected a Git commit, branch, tag or tree")
|
|
499
|
-
return (
|
|
500
|
-
self._git([
|
|
501
|
-
"rev-parse", "--verify", revision + "^{}"
|
|
502
|
-
])
|
|
503
|
-
.decode("ascii")
|
|
504
|
-
.strip()
|
|
505
|
-
)
|
|
506
|
-
|
|
507
|
-
def _git_reference(self, revision, resolved, root, path):
|
|
508
|
-
label = revision if revision == resolved else "{0} ({1})".format(revision, resolved)
|
|
509
|
-
return {
|
|
510
|
-
"label": label + ":" + path,
|
|
511
|
-
"git_source": {
|
|
512
|
-
"repository": str(root),
|
|
513
|
-
"revision": resolved,
|
|
514
|
-
"path": path,
|
|
515
|
-
},
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
def _git_object_reference(self, value):
|
|
519
|
-
revision, separator, path = value.partition(":")
|
|
520
|
-
if not separator or not revision or not path:
|
|
521
|
-
raise ValueError("expected a Git revision:path")
|
|
522
|
-
root = Path(
|
|
523
|
-
self._git(["rev-parse", "--show-toplevel"])
|
|
524
|
-
.decode("utf-8", errors="replace").strip()
|
|
525
|
-
)
|
|
526
|
-
resolved = self._revision(revision)
|
|
527
|
-
directory = self._cwd if path.startswith(("./", "../")) else root
|
|
528
|
-
absolute = Path(os.path.abspath(str(directory / path)))
|
|
529
|
-
try:
|
|
530
|
-
repo_path = absolute.relative_to(root).as_posix()
|
|
531
|
-
except ValueError:
|
|
532
|
-
raise ValueError("Git comparison path must be inside the repository")
|
|
533
|
-
return self._git_reference(revision, resolved, root, repo_path)
|