pyobservablejs 0.0.0rc1__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.
- pyobservablejs/__init__.py +21 -0
- pyobservablejs/_files.py +556 -0
- pyobservablejs/_graph.py +173 -0
- pyobservablejs/_notebook.py +910 -0
- pyobservablejs/_observable.py +173 -0
- pyobservablejs/_serialize.py +72 -0
- pyobservablejs/_variables.py +194 -0
- pyobservablejs/py.typed +1 -0
- pyobservablejs/static/widget.css +1 -0
- pyobservablejs/static/widget.js +665 -0
- pyobservablejs-0.0.0rc1.dist-info/METADATA +179 -0
- pyobservablejs-0.0.0rc1.dist-info/RECORD +14 -0
- pyobservablejs-0.0.0rc1.dist-info/WHEEL +4 -0
- pyobservablejs-0.0.0rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Python API for embedding Observable JavaScript notebooks."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _version
|
|
5
|
+
|
|
6
|
+
from ._notebook import Notebook, html, js, md, ojs
|
|
7
|
+
|
|
8
|
+
globals().pop("annotations", None)
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = _version("pyobservablejs")
|
|
12
|
+
except _PackageNotFoundError:
|
|
13
|
+
__version__ = "unknown"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Notebook",
|
|
17
|
+
"html",
|
|
18
|
+
"js",
|
|
19
|
+
"md",
|
|
20
|
+
"ojs",
|
|
21
|
+
]
|
pyobservablejs/_files.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Prepare Notebook Kit HTML sources for widget rendering.
|
|
2
|
+
|
|
3
|
+
Source-backed notebooks can reference local files with ``FileAttachment`` or
|
|
4
|
+
relative JavaScript imports. This module discovers those references inside real
|
|
5
|
+
notebook script cells and rewrites local assets to data URLs when the widget
|
|
6
|
+
needs a portable, self-contained representation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import mimetypes
|
|
13
|
+
import pathlib
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from html.parser import HTMLParser
|
|
17
|
+
from typing import Any, NamedTuple, cast
|
|
18
|
+
|
|
19
|
+
FileInput = str | pathlib.Path | Mapping[str, Any]
|
|
20
|
+
|
|
21
|
+
_JS_LINE_COMMENT_RE = r"//[^\n]*(?:\n|$)"
|
|
22
|
+
_JS_BLOCK_COMMENT_RE = r"/\*[^*]*\*+(?:[^/*][^*]*\*+)*/"
|
|
23
|
+
_JS_TRIVIA_RE = rf"(?:\s|{_JS_LINE_COMMENT_RE}|{_JS_BLOCK_COMMENT_RE})*"
|
|
24
|
+
_JS_IMPORT_CLAUSE_RE = (
|
|
25
|
+
rf"(?:[^;\"'`/]|/(?![/*])|{_JS_LINE_COMMENT_RE}|{_JS_BLOCK_COMMENT_RE})*?"
|
|
26
|
+
)
|
|
27
|
+
_FILE_ATTACHMENT_RE = re.compile(
|
|
28
|
+
rf"\bFileAttachment{_JS_TRIVIA_RE}\({_JS_TRIVIA_RE}"
|
|
29
|
+
r"([\"'])(?P<path>(?:\\.|(?!\1).)*?)\1"
|
|
30
|
+
rf"{_JS_TRIVIA_RE}\)",
|
|
31
|
+
re.S,
|
|
32
|
+
)
|
|
33
|
+
_NON_JAVASCRIPT_SCRIPT_TYPES = {
|
|
34
|
+
"application/sql",
|
|
35
|
+
"application/x-tex",
|
|
36
|
+
"text/html",
|
|
37
|
+
"text/markdown",
|
|
38
|
+
"text/vnd.graphviz",
|
|
39
|
+
"text/x-python",
|
|
40
|
+
"text/x-r",
|
|
41
|
+
}
|
|
42
|
+
_REGEX_PREFIX_KEYWORDS = {
|
|
43
|
+
"await",
|
|
44
|
+
"case",
|
|
45
|
+
"delete",
|
|
46
|
+
"do",
|
|
47
|
+
"else",
|
|
48
|
+
"extends",
|
|
49
|
+
"in",
|
|
50
|
+
"instanceof",
|
|
51
|
+
"new",
|
|
52
|
+
"of",
|
|
53
|
+
"return",
|
|
54
|
+
"throw",
|
|
55
|
+
"typeof",
|
|
56
|
+
"void",
|
|
57
|
+
"yield",
|
|
58
|
+
}
|
|
59
|
+
_CONTROL_CONDITION_KEYWORDS = {"catch", "for", "if", "while", "with"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _ScriptBlock(NamedTuple):
|
|
63
|
+
start: int
|
|
64
|
+
open: str
|
|
65
|
+
attrs: str
|
|
66
|
+
body_start: int
|
|
67
|
+
body: str
|
|
68
|
+
close: str
|
|
69
|
+
end: int
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_STATIC_IMPORT_RE = re.compile(
|
|
73
|
+
rf"(?P<prefix>\b(?:import|export){_JS_TRIVIA_RE}"
|
|
74
|
+
rf"(?:{_JS_IMPORT_CLAUSE_RE}\bfrom{_JS_TRIVIA_RE})?)"
|
|
75
|
+
r"(?P<quote>[\"'])(?P<path>\.{1,2}/[^\"']+)(?P=quote)",
|
|
76
|
+
re.S,
|
|
77
|
+
)
|
|
78
|
+
_DYNAMIC_IMPORT_RE = re.compile(
|
|
79
|
+
rf"(?P<prefix>\bimport{_JS_TRIVIA_RE}\({_JS_TRIVIA_RE})"
|
|
80
|
+
r"(?P<quote>[\"'])(?P<path>\.{1,2}/[^\"']+)(?P=quote)",
|
|
81
|
+
re.S,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_files(
|
|
86
|
+
files: Mapping[str, FileInput] | None,
|
|
87
|
+
*,
|
|
88
|
+
base_path: str | pathlib.Path | None,
|
|
89
|
+
) -> dict[str, dict[str, Any]]:
|
|
90
|
+
"""Normalize explicit attachment inputs for the frontend registry."""
|
|
91
|
+
|
|
92
|
+
if not files:
|
|
93
|
+
return {}
|
|
94
|
+
base = pathlib.Path(base_path).expanduser().resolve() if base_path else None
|
|
95
|
+
return {
|
|
96
|
+
name: _normalize_file_info(name, value, base_path=base)
|
|
97
|
+
for name, value in files.items()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def prepare_source(
|
|
102
|
+
source: str,
|
|
103
|
+
*,
|
|
104
|
+
base_path: str | pathlib.Path | None,
|
|
105
|
+
embed: bool,
|
|
106
|
+
rewrite_imports: bool,
|
|
107
|
+
) -> tuple[str, dict[str, dict[str, Any]]]:
|
|
108
|
+
"""Prepare Notebook Kit HTML and discovered attachments.
|
|
109
|
+
|
|
110
|
+
When ``embed`` is true and ``base_path`` is set, local ``FileAttachment``
|
|
111
|
+
files are discovered. When ``rewrite_imports`` is also true, relative
|
|
112
|
+
JavaScript imports are rewritten to data URLs. Other option combinations
|
|
113
|
+
return the original source with no discovered attachments.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
if not embed or base_path is None:
|
|
117
|
+
return source, {}
|
|
118
|
+
base = pathlib.Path(base_path).expanduser().resolve()
|
|
119
|
+
attachments = _collect_attachments(source, base)
|
|
120
|
+
if rewrite_imports:
|
|
121
|
+
source = _embed_local_imports(source, base)
|
|
122
|
+
return source, attachments
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _normalize_file_info(
|
|
126
|
+
name: str,
|
|
127
|
+
value: FileInput,
|
|
128
|
+
*,
|
|
129
|
+
base_path: pathlib.Path | None,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
if isinstance(value, Mapping):
|
|
132
|
+
return cast(dict[str, Any], dict(value))
|
|
133
|
+
if isinstance(value, str) and _is_url(value):
|
|
134
|
+
return {"url": value, "mimeType": _guess_mime_type(name)}
|
|
135
|
+
path = pathlib.Path(value).expanduser()
|
|
136
|
+
if not path.is_absolute():
|
|
137
|
+
path = (base_path or pathlib.Path.cwd()) / path
|
|
138
|
+
return _file_info(name, path.resolve())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _collect_attachments(
|
|
142
|
+
source: str, base_path: pathlib.Path
|
|
143
|
+
) -> dict[str, dict[str, Any]]:
|
|
144
|
+
attachments: dict[str, dict[str, Any]] = {}
|
|
145
|
+
for script in _iter_notebook_script_blocks(source):
|
|
146
|
+
if not _is_javascript_script(script.attrs):
|
|
147
|
+
continue
|
|
148
|
+
# Regexes find the Observable FileAttachment call shape. The code mask
|
|
149
|
+
# excludes comments, strings, templates, and regex literals.
|
|
150
|
+
code_mask = _javascript_code_mask(script.body)
|
|
151
|
+
for match in _FILE_ATTACHMENT_RE.finditer(script.body):
|
|
152
|
+
if not code_mask[match.start()]:
|
|
153
|
+
continue
|
|
154
|
+
if not _has_standalone_token_start(script.body, match.start(), code_mask):
|
|
155
|
+
continue
|
|
156
|
+
name = match.group("path")
|
|
157
|
+
if _is_url(name) or name in attachments:
|
|
158
|
+
continue
|
|
159
|
+
path = (base_path / name).resolve()
|
|
160
|
+
if path.is_file():
|
|
161
|
+
attachments[name] = _file_info(name, path)
|
|
162
|
+
return attachments
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _embed_local_imports(source: str, base_path: pathlib.Path) -> str:
|
|
166
|
+
parts: list[str] = []
|
|
167
|
+
cursor = 0
|
|
168
|
+
for script in _iter_notebook_script_blocks(source):
|
|
169
|
+
parts.append(source[cursor : script.start])
|
|
170
|
+
if _is_javascript_script(script.attrs):
|
|
171
|
+
body = _rewrite_import_specifiers(script.body, base_path)
|
|
172
|
+
parts.append(f"{script.open}{body}{script.close}")
|
|
173
|
+
else:
|
|
174
|
+
parts.append(source[script.start : script.end])
|
|
175
|
+
cursor = script.end
|
|
176
|
+
parts.append(source[cursor:])
|
|
177
|
+
return "".join(parts)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _iter_notebook_script_blocks(source: str) -> list[_ScriptBlock]:
|
|
181
|
+
parser = _NotebookScriptBlockParser(source)
|
|
182
|
+
parser.feed(source)
|
|
183
|
+
return parser.blocks
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _NotebookScriptBlockParser(HTMLParser):
|
|
187
|
+
"""Find script bodies inside the Notebook Kit ``<notebook>`` element."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, source: str) -> None:
|
|
190
|
+
super().__init__(convert_charrefs=False)
|
|
191
|
+
self.source = source
|
|
192
|
+
self.blocks: list[_ScriptBlock] = []
|
|
193
|
+
self._line_offsets = _line_offsets(source)
|
|
194
|
+
self._notebook_depth = 0
|
|
195
|
+
self._script: tuple[int, str, str, int] | None = None
|
|
196
|
+
|
|
197
|
+
def handle_starttag(
|
|
198
|
+
self,
|
|
199
|
+
tag: str,
|
|
200
|
+
attrs: list[tuple[str, str | None]],
|
|
201
|
+
) -> None:
|
|
202
|
+
del attrs
|
|
203
|
+
tag = tag.lower()
|
|
204
|
+
if tag == "notebook" and self._script is None:
|
|
205
|
+
self._notebook_depth += 1
|
|
206
|
+
return
|
|
207
|
+
if tag != "script" or not self._notebook_depth or self._script is not None:
|
|
208
|
+
return
|
|
209
|
+
start = self._offset()
|
|
210
|
+
open_tag = self.get_starttag_text() or ""
|
|
211
|
+
if not open_tag:
|
|
212
|
+
return
|
|
213
|
+
body_start = start + len(open_tag)
|
|
214
|
+
self._script = (start, open_tag, _script_attrs_text(open_tag), body_start)
|
|
215
|
+
|
|
216
|
+
def handle_endtag(self, tag: str) -> None:
|
|
217
|
+
tag = tag.lower()
|
|
218
|
+
if tag == "script" and self._script is not None:
|
|
219
|
+
close_start = self._offset()
|
|
220
|
+
close_end = self.source.find(">", close_start)
|
|
221
|
+
if close_end == -1:
|
|
222
|
+
self._script = None
|
|
223
|
+
return
|
|
224
|
+
start, open_tag, attrs, body_start = self._script
|
|
225
|
+
self.blocks.append(
|
|
226
|
+
_ScriptBlock(
|
|
227
|
+
start=start,
|
|
228
|
+
open=open_tag,
|
|
229
|
+
attrs=attrs,
|
|
230
|
+
body_start=body_start,
|
|
231
|
+
body=self.source[body_start:close_start],
|
|
232
|
+
close=self.source[close_start : close_end + 1],
|
|
233
|
+
end=close_end + 1,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
self._script = None
|
|
237
|
+
return
|
|
238
|
+
if tag == "notebook" and self._script is None and self._notebook_depth:
|
|
239
|
+
self._notebook_depth -= 1
|
|
240
|
+
|
|
241
|
+
def _offset(self) -> int:
|
|
242
|
+
line, column = self.getpos()
|
|
243
|
+
return self._line_offsets[line - 1] + column
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _line_offsets(source: str) -> list[int]:
|
|
247
|
+
offsets = [0]
|
|
248
|
+
offsets.extend(match.end() for match in re.finditer("\n", source))
|
|
249
|
+
return offsets
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _script_attrs_text(open_tag: str) -> str:
|
|
253
|
+
end = -2 if open_tag.endswith("/>") else -1
|
|
254
|
+
return open_tag[len("<script") : end]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _is_javascript_script(attrs: str) -> bool:
|
|
258
|
+
value = _script_attrs(attrs).get("type", "module")
|
|
259
|
+
script_type = value.strip().lower()
|
|
260
|
+
return script_type not in _NON_JAVASCRIPT_SCRIPT_TYPES
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _script_attrs(attrs: str) -> dict[str, str]:
|
|
264
|
+
parser = _ScriptAttrParser()
|
|
265
|
+
parser.feed(f"<script{attrs}></script>")
|
|
266
|
+
return parser.attrs
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _ScriptAttrParser(HTMLParser):
|
|
270
|
+
def __init__(self) -> None:
|
|
271
|
+
super().__init__(convert_charrefs=True)
|
|
272
|
+
self.attrs: dict[str, str] = {}
|
|
273
|
+
|
|
274
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
275
|
+
if tag.lower() != "script":
|
|
276
|
+
return
|
|
277
|
+
self.attrs = {name.lower(): value or "" for name, value in attrs}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _rewrite_import_specifiers(source: str, base_path: pathlib.Path) -> str:
|
|
281
|
+
"""Inline relative JavaScript imports that can run inside a portable widget."""
|
|
282
|
+
|
|
283
|
+
def replace(
|
|
284
|
+
match: re.Match[str],
|
|
285
|
+
code_mask: list[bool],
|
|
286
|
+
*,
|
|
287
|
+
require_standalone: bool = True,
|
|
288
|
+
) -> str:
|
|
289
|
+
if not code_mask[match.start()]:
|
|
290
|
+
return match.group(0)
|
|
291
|
+
if require_standalone and not _has_standalone_token_start(
|
|
292
|
+
source,
|
|
293
|
+
match.start(),
|
|
294
|
+
code_mask,
|
|
295
|
+
):
|
|
296
|
+
return match.group(0)
|
|
297
|
+
path = match.group("path")
|
|
298
|
+
resolved = (base_path / path).resolve()
|
|
299
|
+
if not resolved.is_file():
|
|
300
|
+
return match.group(0)
|
|
301
|
+
return f"{match.group('prefix')}{match.group('quote')}{_data_url(resolved)}{match.group('quote')}"
|
|
302
|
+
|
|
303
|
+
code_mask = _javascript_code_mask(source)
|
|
304
|
+
source = _STATIC_IMPORT_RE.sub(lambda match: replace(match, code_mask), source)
|
|
305
|
+
code_mask = _javascript_code_mask(source)
|
|
306
|
+
return _DYNAMIC_IMPORT_RE.sub(lambda match: replace(match, code_mask), source)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _javascript_code_mask(source: str) -> list[bool]:
|
|
310
|
+
"""Mark character positions that belong to executable JavaScript code."""
|
|
311
|
+
|
|
312
|
+
mask = [True] * len(source)
|
|
313
|
+
index = 0
|
|
314
|
+
while index < len(source):
|
|
315
|
+
char = source[index]
|
|
316
|
+
next_char = source[index + 1] if index + 1 < len(source) else ""
|
|
317
|
+
if char == "/" and next_char == "/":
|
|
318
|
+
index = _mask_until(source, mask, index, "\n")
|
|
319
|
+
elif char == "/" and next_char == "*":
|
|
320
|
+
index = _mask_until(source, mask, index, "*/")
|
|
321
|
+
elif char == "/" and _starts_regex_literal(source, index, mask):
|
|
322
|
+
index = _mask_regex(source, mask, index)
|
|
323
|
+
elif char == "`":
|
|
324
|
+
index = _mask_template(source, mask, index)
|
|
325
|
+
elif char in {"'", '"'}:
|
|
326
|
+
index = _mask_string(source, mask, index, char)
|
|
327
|
+
else:
|
|
328
|
+
index += 1
|
|
329
|
+
return mask
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _mask_until(source: str, mask: list[bool], start: int, marker: str) -> int:
|
|
333
|
+
end = source.find(marker, start + len(marker))
|
|
334
|
+
if end == -1:
|
|
335
|
+
end = len(source)
|
|
336
|
+
else:
|
|
337
|
+
end += len(marker)
|
|
338
|
+
for index in range(start, end):
|
|
339
|
+
mask[index] = False
|
|
340
|
+
return end
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _mask_string(source: str, mask: list[bool], start: int, quote: str) -> int:
|
|
344
|
+
index = start
|
|
345
|
+
escaped = False
|
|
346
|
+
while index < len(source):
|
|
347
|
+
mask[index] = False
|
|
348
|
+
char = source[index]
|
|
349
|
+
if escaped:
|
|
350
|
+
escaped = False
|
|
351
|
+
elif char == "\\":
|
|
352
|
+
escaped = True
|
|
353
|
+
elif char == quote and index != start:
|
|
354
|
+
return index + 1
|
|
355
|
+
index += 1
|
|
356
|
+
return index
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _mask_template(source: str, mask: list[bool], start: int) -> int:
|
|
360
|
+
index = start
|
|
361
|
+
while index < len(source):
|
|
362
|
+
mask[index] = False
|
|
363
|
+
char = source[index]
|
|
364
|
+
next_char = source[index + 1] if index + 1 < len(source) else ""
|
|
365
|
+
if char == "\\":
|
|
366
|
+
if index + 1 < len(source):
|
|
367
|
+
mask[index + 1] = False
|
|
368
|
+
index += 2
|
|
369
|
+
elif char == "$" and next_char == "{":
|
|
370
|
+
mask[index + 1] = False
|
|
371
|
+
index = _mask_template_expression(source, mask, index + 2)
|
|
372
|
+
elif char == "`" and index != start:
|
|
373
|
+
return index + 1
|
|
374
|
+
else:
|
|
375
|
+
index += 1
|
|
376
|
+
return index
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _mask_template_expression(source: str, mask: list[bool], start: int) -> int:
|
|
380
|
+
depth = 1
|
|
381
|
+
index = start
|
|
382
|
+
while index < len(source):
|
|
383
|
+
char = source[index]
|
|
384
|
+
next_char = source[index + 1] if index + 1 < len(source) else ""
|
|
385
|
+
if char == "/" and next_char == "/":
|
|
386
|
+
index = _mask_until(source, mask, index, "\n")
|
|
387
|
+
elif char == "/" and next_char == "*":
|
|
388
|
+
index = _mask_until(source, mask, index, "*/")
|
|
389
|
+
elif char == "/" and _starts_regex_literal(source, index, mask):
|
|
390
|
+
index = _mask_regex(source, mask, index)
|
|
391
|
+
elif char == "`":
|
|
392
|
+
index = _mask_template(source, mask, index)
|
|
393
|
+
elif char in {"'", '"'}:
|
|
394
|
+
index = _mask_string(source, mask, index, char)
|
|
395
|
+
elif char == "{":
|
|
396
|
+
depth += 1
|
|
397
|
+
index += 1
|
|
398
|
+
elif char == "}":
|
|
399
|
+
depth -= 1
|
|
400
|
+
mask[index] = False
|
|
401
|
+
index += 1
|
|
402
|
+
if depth == 0:
|
|
403
|
+
return index
|
|
404
|
+
else:
|
|
405
|
+
index += 1
|
|
406
|
+
return index
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _starts_regex_literal(source: str, start: int, mask: list[bool]) -> bool:
|
|
410
|
+
index = _previous_code_index(source, start, mask)
|
|
411
|
+
if index < 0:
|
|
412
|
+
return True
|
|
413
|
+
if _is_js_identifier_part(source[index]):
|
|
414
|
+
keyword, keyword_start = _identifier_ending_at(source, index)
|
|
415
|
+
return keyword in _REGEX_PREFIX_KEYWORDS and _is_standalone_keyword_start(
|
|
416
|
+
source, keyword_start, mask
|
|
417
|
+
)
|
|
418
|
+
if source[index] == ")":
|
|
419
|
+
open_paren = _matching_open_paren(source, index, mask)
|
|
420
|
+
if open_paren is not None:
|
|
421
|
+
keyword = _keyword_before(source, open_paren, mask)
|
|
422
|
+
return keyword in _CONTROL_CONDITION_KEYWORDS
|
|
423
|
+
return source[index] in "({[=,:;!&|?+-*~%^<>"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _previous_code_index(source: str, start: int, mask: list[bool]) -> int:
|
|
427
|
+
index = start - 1
|
|
428
|
+
while index >= 0 and (source[index].isspace() or not mask[index]):
|
|
429
|
+
index -= 1
|
|
430
|
+
return index
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _identifier_ending_at(source: str, end_index: int) -> tuple[str, int]:
|
|
434
|
+
start = end_index
|
|
435
|
+
while start >= 0 and _is_js_identifier_part(source[start]):
|
|
436
|
+
start -= 1
|
|
437
|
+
return source[start + 1 : end_index + 1], start + 1
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _is_js_identifier_part(char: str) -> bool:
|
|
441
|
+
return char == "$" or char == "_" or char.isidentifier() or char.isdigit()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _matching_open_paren(
|
|
445
|
+
source: str,
|
|
446
|
+
close_paren: int,
|
|
447
|
+
mask: list[bool],
|
|
448
|
+
) -> int | None:
|
|
449
|
+
depth = 0
|
|
450
|
+
index = close_paren
|
|
451
|
+
while index >= 0:
|
|
452
|
+
if not mask[index]:
|
|
453
|
+
index -= 1
|
|
454
|
+
continue
|
|
455
|
+
if source[index] == ")":
|
|
456
|
+
depth += 1
|
|
457
|
+
elif source[index] == "(":
|
|
458
|
+
depth -= 1
|
|
459
|
+
if depth == 0:
|
|
460
|
+
return index
|
|
461
|
+
index -= 1
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _keyword_before(source: str, index: int, mask: list[bool]) -> str:
|
|
466
|
+
keyword_end = _previous_code_index(source, index, mask)
|
|
467
|
+
if keyword_end < 0 or not _is_js_identifier_part(source[keyword_end]):
|
|
468
|
+
return ""
|
|
469
|
+
keyword, keyword_start = _identifier_ending_at(source, keyword_end)
|
|
470
|
+
if keyword == "await" and _keyword_before(source, keyword_start, mask) == "for":
|
|
471
|
+
return "for"
|
|
472
|
+
if not _is_standalone_keyword_start(source, keyword_start, mask):
|
|
473
|
+
return ""
|
|
474
|
+
return keyword
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _is_standalone_keyword_start(
|
|
478
|
+
source: str,
|
|
479
|
+
keyword_start: int,
|
|
480
|
+
mask: list[bool],
|
|
481
|
+
) -> bool:
|
|
482
|
+
before_keyword = _previous_code_index(source, keyword_start, mask)
|
|
483
|
+
return before_keyword < 0 or (
|
|
484
|
+
source[before_keyword] not in {".", "#"}
|
|
485
|
+
and not _is_js_identifier_part(source[before_keyword])
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _has_standalone_token_start(
|
|
490
|
+
source: str,
|
|
491
|
+
token_start: int,
|
|
492
|
+
mask: list[bool],
|
|
493
|
+
) -> bool:
|
|
494
|
+
before = _previous_code_index(source, token_start, mask)
|
|
495
|
+
if before < 0:
|
|
496
|
+
return True
|
|
497
|
+
if source[before] in {".", "#"}:
|
|
498
|
+
return False
|
|
499
|
+
return before != token_start - 1 or not _is_js_identifier_part(source[before])
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _mask_regex(source: str, mask: list[bool], start: int) -> int:
|
|
503
|
+
index = start
|
|
504
|
+
escaped = False
|
|
505
|
+
in_character_class = False
|
|
506
|
+
while index < len(source):
|
|
507
|
+
mask[index] = False
|
|
508
|
+
char = source[index]
|
|
509
|
+
if escaped:
|
|
510
|
+
escaped = False
|
|
511
|
+
elif char == "\\":
|
|
512
|
+
escaped = True
|
|
513
|
+
elif char == "[":
|
|
514
|
+
in_character_class = True
|
|
515
|
+
elif char == "]":
|
|
516
|
+
in_character_class = False
|
|
517
|
+
elif char == "/" and index != start and not in_character_class:
|
|
518
|
+
index += 1
|
|
519
|
+
while index < len(source) and (
|
|
520
|
+
source[index].isalpha() or source[index].isdigit()
|
|
521
|
+
):
|
|
522
|
+
mask[index] = False
|
|
523
|
+
index += 1
|
|
524
|
+
return index
|
|
525
|
+
index += 1
|
|
526
|
+
return index
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _file_info(name: str, path: pathlib.Path) -> dict[str, Any]:
|
|
530
|
+
return {
|
|
531
|
+
"url": _data_url(path),
|
|
532
|
+
"mimeType": _guess_mime_type(name),
|
|
533
|
+
"size": path.stat().st_size,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _data_url(path: pathlib.Path) -> str:
|
|
538
|
+
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
539
|
+
return f"data:{_guess_mime_type(path.name)};base64,{data}"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _guess_mime_type(name: str) -> str:
|
|
543
|
+
custom = {
|
|
544
|
+
".arrow": "application/vnd.apache.arrow.file",
|
|
545
|
+
".parquet": "application/vnd.apache.parquet",
|
|
546
|
+
}
|
|
547
|
+
suffix = pathlib.PurePosixPath(name).suffix.lower()
|
|
548
|
+
return (
|
|
549
|
+
custom.get(suffix)
|
|
550
|
+
or mimetypes.guess_type(name)[0]
|
|
551
|
+
or "application/octet-stream"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _is_url(value: str) -> bool:
|
|
556
|
+
return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", value))
|