python-codex 0.3.2__py3-none-any.whl → 0.3.3__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.
@@ -0,0 +1,533 @@
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)
@@ -370,7 +370,7 @@
370
370
  </div>
371
371
  </div>
372
372
  <form id="workspaceForm">
373
- <label>Board
373
+ <label>Canvas file
374
374
  <input id="boardInput" name="board" placeholder="/tmp/pcws-xxxx.html" spellcheck="false">
375
375
  </label>
376
376
  <label>Directory
@@ -458,7 +458,7 @@
458
458
  const paths = document.createElement("div");
459
459
  paths.className = "paths";
460
460
  paths.appendChild(pathLine("Directory", workspace.work_dir || ""));
461
- paths.appendChild(pathLine("Board", workspace.board_path || ""));
461
+ paths.appendChild(pathLine("Canvas", workspace.board_path || ""));
462
462
 
463
463
  const actions = document.createElement("div");
464
464
  actions.className = "actions";
@@ -9,6 +9,8 @@ from uuid import uuid4
9
9
 
10
10
  from pycodex.utils import uuid7_string
11
11
 
12
+ from .workspace_views import WorkspaceViewStore
13
+
12
14
  SessionFactory = typing.Callable[[], object]
13
15
 
14
16
 
@@ -246,6 +248,7 @@ class WorkspaceSessionManager:
246
248
  session_factory: "SessionFactory",
247
249
  board_path: "typing.Union[Path, None]" = None,
248
250
  persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
251
+ views=None,
249
252
  ) -> None:
250
253
  self._session_factory = session_factory
251
254
  self._sessions: "typing.Dict[str, object]" = {}
@@ -255,6 +258,7 @@ class WorkspaceSessionManager:
255
258
  self._lock = None
256
259
  self._state_store = WorkspaceStateStore(board_path)
257
260
  self._persist_callback = persist_callback
261
+ self.views = views if views is not None else WorkspaceViewStore(board_path)
258
262
 
259
263
  def _get_lock(self):
260
264
  if self._lock is None: