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.
@@ -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
+ ]
@@ -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))