marimo-dev 0.2.8__tar.gz → 0.3.0__tar.gz
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.
- {marimo_dev-0.2.8 → marimo_dev-0.3.0}/PKG-INFO +9 -4
- {marimo_dev-0.2.8 → marimo_dev-0.3.0}/README.md +5 -1
- {marimo_dev-0.2.8 → marimo_dev-0.3.0}/pyproject.toml +4 -6
- {marimo_dev-0.2.8 → marimo_dev-0.3.0}/src/marimo_dev/__init__.py +8 -9
- marimo_dev-0.3.0/src/marimo_dev/build.py +226 -0
- {marimo_dev-0.2.8 → marimo_dev-0.3.0}/src/marimo_dev/core.py +3 -2
- marimo_dev-0.3.0/src/marimo_dev/docs.py +310 -0
- {marimo_dev-0.2.8 → marimo_dev-0.3.0}/src/marimo_dev/pkg.py +1 -1
- marimo_dev-0.3.0/src/marimo_dev/publish.py +93 -0
- {marimo_dev-0.2.8 → marimo_dev-0.3.0}/src/marimo_dev/read.py +5 -2
- marimo_dev-0.2.8/src/marimo_dev/build.py +0 -119
- marimo_dev-0.2.8/src/marimo_dev/docs.py +0 -236
- marimo_dev-0.2.8/src/marimo_dev/publish.py +0 -31
- {marimo_dev-0.2.8 → marimo_dev-0.3.0}/src/marimo_dev/cli.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: marimo-dev
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Build and publish python packages from marimo notebooks
|
|
5
5
|
Author: Deufel
|
|
6
6
|
Author-email: Deufel <MDeufel13@gmail.com>
|
|
7
7
|
License: MIT
|
|
8
|
+
Requires-Dist: anthropic>=0.84.0
|
|
8
9
|
Requires-Dist: fastcore>=1.10.0
|
|
9
10
|
Requires-Dist: ipython>=9.8.0
|
|
10
11
|
Requires-Dist: marimo>=0.19.7
|
|
@@ -13,14 +14,18 @@ Requires-Dist: pydantic-ai-slim>=1.50.0
|
|
|
13
14
|
Requires-Dist: pytest>=9.0.2
|
|
14
15
|
Requires-Dist: python-fasthtml>=0.12.37
|
|
15
16
|
Requires-Dist: youtube-transcript-api>=1.2.3
|
|
16
|
-
Requires-Python: >=3.
|
|
17
|
-
Project-URL: Repository, https://github.com/deufel/m-dev
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
18
|
Project-URL: PyPI, https://pypi.org/project/marimo-dev/
|
|
19
|
+
Project-URL: Repository, https://github.com/deufel/m-dev
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
|
|
21
22
|
# marimo-dev
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
> [!WARNING]
|
|
25
|
+
> This project is under active development and is not an official marimo tool - Mar 2026
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Build Python packages (and applications[in progress]) from Marimo notebooks.
|
|
24
29
|
|
|
25
30
|
## Why this exists
|
|
26
31
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# marimo-dev
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> This project is under active development and is not an official marimo tool - Mar 2026
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Build Python packages (and applications[in progress]) from Marimo notebooks.
|
|
4
8
|
|
|
5
9
|
## Why this exists
|
|
6
10
|
|
|
@@ -4,11 +4,12 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "marimo-dev"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Build and publish python packages from marimo notebooks"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
11
|
dependencies = [
|
|
12
|
+
"anthropic>=0.84.0",
|
|
12
13
|
"fastcore>=1.10.0",
|
|
13
14
|
"ipython>=9.8.0",
|
|
14
15
|
"marimo>=0.19.7",
|
|
@@ -51,9 +52,6 @@ PyPI = "https://pypi.org/project/marimo-dev/"
|
|
|
51
52
|
skip_prefixes = ["XX_", "test_"]
|
|
52
53
|
|
|
53
54
|
# Remove this when ready to upload this is a saftey net for not accidentally uploading
|
|
54
|
-
classifiers = ["Private :: Do Not Upload"]
|
|
55
|
+
# classifiers = ["Private :: Do Not Upload"]
|
|
55
56
|
|
|
56
|
-
# Development packages
|
|
57
|
-
[dependency-groups]
|
|
58
|
-
dev = ["anthropic>=0.72.0", "marimo[mcp]>=0.18.4" , "pytest>=8.4.2"]
|
|
59
57
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"""Build and publish python packages from marimo notebooks"""
|
|
2
|
-
__version__ = '0.
|
|
2
|
+
__version__ = '0.3.0'
|
|
3
3
|
__author__ = 'Deufel'
|
|
4
4
|
from .core import Config, read_config, Kind, Param, Node
|
|
5
5
|
from .read import inline_doc, parse_params, parse_hash_pipe, parse_class_params, parse_class_methods, parse_ret, src_with_decs, is_export, parse_import, parse_const, parse_export, parse_node, parse_file, read_meta, nb_name, scan
|
|
6
6
|
from .pkg import clean, write, write_mod, rewrite_imports, write_init
|
|
7
|
-
from .docs import
|
|
8
|
-
from .build import build, tidy, nuke, get_pypi_name, extract_import_names, pep723_header, bundle
|
|
9
|
-
from .publish import publish
|
|
7
|
+
from .docs import nb_path, render_node, render_module_page, build_docs, export_wasm, write_nojekyll, html_preview, render_index_page, Icon, write_highlighter
|
|
8
|
+
from .build import build, tidy, nuke, get_pypi_name, extract_import_names, pep723_header, write_llms, bundle, bundle_notebook
|
|
9
|
+
from .publish import check_credentials, check_pypi_auth, publish
|
|
10
10
|
from .cli import main
|
|
11
11
|
__all__ = [
|
|
12
12
|
"Config",
|
|
@@ -17,12 +17,12 @@ __all__ = [
|
|
|
17
17
|
"build",
|
|
18
18
|
"build_docs",
|
|
19
19
|
"bundle",
|
|
20
|
+
"bundle_notebook",
|
|
21
|
+
"check_credentials",
|
|
22
|
+
"check_pypi_auth",
|
|
20
23
|
"clean",
|
|
21
|
-
"cls_sig",
|
|
22
|
-
"exp_type",
|
|
23
24
|
"export_wasm",
|
|
24
25
|
"extract_import_names",
|
|
25
|
-
"fn_sig",
|
|
26
26
|
"get_pypi_name",
|
|
27
27
|
"html_preview",
|
|
28
28
|
"inline_doc",
|
|
@@ -48,13 +48,12 @@ __all__ = [
|
|
|
48
48
|
"render_index_page",
|
|
49
49
|
"render_module_page",
|
|
50
50
|
"render_node",
|
|
51
|
-
"render_param",
|
|
52
51
|
"rewrite_imports",
|
|
53
52
|
"scan",
|
|
54
|
-
"sig",
|
|
55
53
|
"src_with_decs",
|
|
56
54
|
"tidy",
|
|
57
55
|
"write",
|
|
56
|
+
"write_highlighter",
|
|
58
57
|
"write_init",
|
|
59
58
|
"write_llms",
|
|
60
59
|
"write_mod",
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from .core import Kind, Param, Node, Config, read_config
|
|
2
|
+
from .read import scan, read_meta
|
|
3
|
+
from .pkg import write_mod, write_init, clean
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import ast, shutil, re, sys
|
|
6
|
+
|
|
7
|
+
IMPORT_TO_PYPI = {'bs4': 'beautifulsoup4', 'PIL': 'pillow', 'cv2': 'opencv-python', 'sklearn': 'scikit-learn', 'yaml': 'pyyaml'}
|
|
8
|
+
|
|
9
|
+
def build(
|
|
10
|
+
root='.', # root directory containing pyproject.toml
|
|
11
|
+
)->str: # path to built package
|
|
12
|
+
"Build a Python package from notebooks."
|
|
13
|
+
cfg = read_config(root)
|
|
14
|
+
meta, mods = scan(root)
|
|
15
|
+
mod_names = [name for name, _ in mods]
|
|
16
|
+
pkg = Path(root) / cfg.out / meta['name'].replace('-', '_')
|
|
17
|
+
if pkg.exists(): shutil.rmtree(pkg)
|
|
18
|
+
pkg.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
for name, nodes in mods:
|
|
20
|
+
stripped = re.sub(r'^[a-z]_', '', name)
|
|
21
|
+
if stripped != 'index' and any(n.kind == Kind.EXP for n in nodes): write_mod(pkg/f'{stripped}.py', nodes, mod_names)
|
|
22
|
+
write_init(pkg/'__init__.py', meta, mods)
|
|
23
|
+
all_exp = [n for _, nodes in mods for n in nodes if n.kind == Kind.EXP]
|
|
24
|
+
if all_exp: write_llms()
|
|
25
|
+
return str(pkg)
|
|
26
|
+
|
|
27
|
+
def tidy():
|
|
28
|
+
"Remove cache and temporary files (__pycache__, __marimo__, .pytest_cache, etc)."
|
|
29
|
+
import shutil
|
|
30
|
+
for p in Path('.').rglob('__pycache__'): shutil.rmtree(p, ignore_errors=True)
|
|
31
|
+
for p in Path('.').rglob('__marimo__'): shutil.rmtree(p, ignore_errors=True)
|
|
32
|
+
for p in Path('.').rglob('.pytest_cache'): shutil.rmtree(p, ignore_errors=True)
|
|
33
|
+
for p in Path('.').rglob('*.pyc'): p.unlink(missing_ok=True)
|
|
34
|
+
print("Cleaned cache files")
|
|
35
|
+
|
|
36
|
+
def nuke():
|
|
37
|
+
"Remove all build artifacts (dist, docs, src) and cache files."
|
|
38
|
+
import shutil
|
|
39
|
+
tidy()
|
|
40
|
+
for d in ['dist', 'docs', 'src', 'temp']: shutil.rmtree(d, ignore_errors=True)
|
|
41
|
+
print("Nuked build artifacts")
|
|
42
|
+
|
|
43
|
+
def get_pypi_name(import_name):
|
|
44
|
+
"Map import name to PyPI package name."
|
|
45
|
+
root = import_name.split('.')[0]
|
|
46
|
+
return IMPORT_TO_PYPI.get(root, root)
|
|
47
|
+
|
|
48
|
+
def extract_import_names(nodes):
|
|
49
|
+
"Extract top-level module names from import nodes."
|
|
50
|
+
names = set()
|
|
51
|
+
for n in nodes:
|
|
52
|
+
if n.kind != Kind.IMP: continue
|
|
53
|
+
tree = ast.parse(n.src)
|
|
54
|
+
for stmt in ast.walk(tree):
|
|
55
|
+
if isinstance(stmt, ast.Import):
|
|
56
|
+
for alias in stmt.names:
|
|
57
|
+
names.add(alias.name.split('.')[0])
|
|
58
|
+
elif isinstance(stmt, ast.ImportFrom) and stmt.module:
|
|
59
|
+
names.add(stmt.module.split('.')[0])
|
|
60
|
+
return names
|
|
61
|
+
|
|
62
|
+
def pep723_header(deps):
|
|
63
|
+
"Generate PEP 723 inline script metadata."
|
|
64
|
+
deps_str = ', '.join(f'"{d}"' for d in sorted(deps))
|
|
65
|
+
return f'# /// script\n# dependencies = [{deps_str}]\n# ///\n'
|
|
66
|
+
|
|
67
|
+
def write_llms(root='.'):
|
|
68
|
+
"Generate llms.txt and llms-full.txt from parsed notebooks."
|
|
69
|
+
cfg = read_config(root)
|
|
70
|
+
meta, mods = scan(root)
|
|
71
|
+
name = meta['name']
|
|
72
|
+
desc = meta.get('description', '')
|
|
73
|
+
docs_dir = Path(root) / cfg.docs
|
|
74
|
+
docs_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
# llms-full.txt — complete cleaned source
|
|
77
|
+
full_parts = [f"# {name}\n\n> {desc}\n"]
|
|
78
|
+
for mod_name, nodes in mods:
|
|
79
|
+
exports = [n for n in nodes if n.kind == Kind.EXP]
|
|
80
|
+
if not exports: continue
|
|
81
|
+
stripped = re.sub(r'^[a-z]_', '', mod_name)
|
|
82
|
+
full_parts.append(f"## {stripped}\n")
|
|
83
|
+
for n in exports:
|
|
84
|
+
full_parts.append(clean(n.src))
|
|
85
|
+
Path(docs_dir / 'llms-full.txt').write_text('\n\n'.join(full_parts) + '\n')
|
|
86
|
+
|
|
87
|
+
# llms.txt — summary with links
|
|
88
|
+
base_url = meta.get('url', '')
|
|
89
|
+
lines = [f"# {name}\n", f"> {desc}\n"]
|
|
90
|
+
for mod_name, nodes in mods:
|
|
91
|
+
exports = [n for n in nodes if n.kind == Kind.EXP]
|
|
92
|
+
if not exports: continue
|
|
93
|
+
stripped = re.sub(r'^[a-z]_', '', mod_name)
|
|
94
|
+
names = ', '.join(n.name for n in exports)
|
|
95
|
+
lines.append(f"- [{stripped}]({base_url}/{stripped}): {names}")
|
|
96
|
+
lines.append(f"\n- [llms-full.txt]({base_url}/llms-full.txt): Complete source code")
|
|
97
|
+
Path(docs_dir / 'llms.txt').write_text('\n'.join(lines) + '\n')
|
|
98
|
+
|
|
99
|
+
return f"Wrote {docs_dir}/llms.txt and llms-full.txt"
|
|
100
|
+
|
|
101
|
+
def bundle(root='.', name=None):
|
|
102
|
+
"Bundle all notebooks into a single Python file with PEP 723 dependencies."
|
|
103
|
+
cfg = read_config(root)
|
|
104
|
+
meta, mods = scan(root)
|
|
105
|
+
|
|
106
|
+
# Get notebook filenames to filter out local imports
|
|
107
|
+
nbs_dir = Path(root) / cfg.nbs
|
|
108
|
+
nb_names = {f.stem for f in nbs_dir.glob('*.py')}
|
|
109
|
+
|
|
110
|
+
# Collect all nodes
|
|
111
|
+
all_nodes = [n for _, nodes in mods for n in nodes]
|
|
112
|
+
|
|
113
|
+
# Extract dependencies
|
|
114
|
+
import_names = extract_import_names(all_nodes)
|
|
115
|
+
mod_names = [m for m, _ in mods]
|
|
116
|
+
|
|
117
|
+
# Filter out local modules, notebook names, and stdlib
|
|
118
|
+
external = {get_pypi_name(n) for n in import_names
|
|
119
|
+
if n not in mod_names
|
|
120
|
+
and n not in nb_names
|
|
121
|
+
and n not in sys.stdlib_module_names}
|
|
122
|
+
|
|
123
|
+
# Build output
|
|
124
|
+
header = pep723_header(external)
|
|
125
|
+
|
|
126
|
+
# Filter out local imports, dedupe externals
|
|
127
|
+
seen = set()
|
|
128
|
+
filtered_imports = []
|
|
129
|
+
for n in all_nodes:
|
|
130
|
+
if n.kind != Kind.IMP: continue
|
|
131
|
+
# Skip if it's a local notebook import
|
|
132
|
+
if any(nb in n.src for nb in nb_names): continue
|
|
133
|
+
if n.src not in seen:
|
|
134
|
+
seen.add(n.src)
|
|
135
|
+
filtered_imports.append(n.src)
|
|
136
|
+
|
|
137
|
+
imports = '\n'.join(filtered_imports)
|
|
138
|
+
|
|
139
|
+
consts = '\n'.join(n.src for n in all_nodes if n.kind == Kind.CONST)
|
|
140
|
+
setup = '\n'.join(n.src for n in all_nodes if n.kind == Kind.SETUP)
|
|
141
|
+
exports = '\n\n'.join(clean(n.src) for n in all_nodes if n.kind == Kind.EXP)
|
|
142
|
+
|
|
143
|
+
content = '\n\n'.join(p for p in [header, imports, consts, setup, exports] if p.strip())
|
|
144
|
+
|
|
145
|
+
# Determine output path
|
|
146
|
+
if name:
|
|
147
|
+
out_path = Path(root) / name
|
|
148
|
+
else:
|
|
149
|
+
out_path = Path(root) / cfg.out / meta['name'].replace('-', '_') / '__init__.py'
|
|
150
|
+
|
|
151
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
out_path.write_text(content)
|
|
153
|
+
return f"Bundled to {out_path}"
|
|
154
|
+
|
|
155
|
+
def bundle_notebook(root='.', name=None, include_cells=False):
|
|
156
|
+
"Bundle all notebooks into a single marimo notebook file."
|
|
157
|
+
cfg = read_config(root)
|
|
158
|
+
nbs_dir = Path(root) / cfg.nbs
|
|
159
|
+
|
|
160
|
+
# Get ordered notebook files (skip XX_ and test_)
|
|
161
|
+
files = sorted(f for f in nbs_dir.glob('*.py')
|
|
162
|
+
if not any(f.name.startswith(p) for p in cfg.skip_prefixes))
|
|
163
|
+
nb_stems = {f.stem for f in files}
|
|
164
|
+
|
|
165
|
+
# Decorators to keep
|
|
166
|
+
keep = {'app.function', 'app.class_definition'}
|
|
167
|
+
if include_cells: keep.add('app.cell')
|
|
168
|
+
|
|
169
|
+
setup_lines = []
|
|
170
|
+
cells = []
|
|
171
|
+
|
|
172
|
+
for f in files:
|
|
173
|
+
txt = f.read_text()
|
|
174
|
+
tree = ast.parse(txt)
|
|
175
|
+
lines = txt.splitlines()
|
|
176
|
+
|
|
177
|
+
for node in tree.body:
|
|
178
|
+
# Collect setup block contents
|
|
179
|
+
if isinstance(node, ast.With):
|
|
180
|
+
for s in node.body:
|
|
181
|
+
line = ast.get_source_segment(txt, s)
|
|
182
|
+
if not line: continue
|
|
183
|
+
# Skip cross-notebook imports
|
|
184
|
+
if any(nb in line for nb in nb_stems): continue
|
|
185
|
+
if line.strip() not in setup_lines: setup_lines.append(line.strip())
|
|
186
|
+
|
|
187
|
+
# Collect decorated cells
|
|
188
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
189
|
+
src = ast.get_source_segment(txt, node)
|
|
190
|
+
if not src: continue
|
|
191
|
+
# Check decorators
|
|
192
|
+
dec_names = []
|
|
193
|
+
for d in getattr(node, 'decorator_list', []):
|
|
194
|
+
if isinstance(d, ast.Attribute): dec_names.append(f"{ast.unparse(d)}")
|
|
195
|
+
elif isinstance(d, ast.Name): dec_names.append(d.id)
|
|
196
|
+
if any(d in keep for d in dec_names):
|
|
197
|
+
# Reconstruct with decorators
|
|
198
|
+
dec_lines = [lines[d.lineno - 1] for d in node.decorator_list]
|
|
199
|
+
block = '\n'.join(dec_lines) + '\n' + src
|
|
200
|
+
cells.append(block)
|
|
201
|
+
|
|
202
|
+
# Check for name collisions
|
|
203
|
+
seen = {}
|
|
204
|
+
for f in files:
|
|
205
|
+
txt = f.read_text()
|
|
206
|
+
tree = ast.parse(txt)
|
|
207
|
+
for node in tree.body:
|
|
208
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): continue
|
|
209
|
+
if node.name.startswith('_'): continue
|
|
210
|
+
if node.name in seen:
|
|
211
|
+
raise ValueError(f"Name collision: '{node.name}' defined in both {seen[node.name]} and {f.name}")
|
|
212
|
+
seen[node.name] = f.name
|
|
213
|
+
|
|
214
|
+
# Build output
|
|
215
|
+
meta = read_meta(root)
|
|
216
|
+
header = f'import marimo\n\n__generated_with = "0.20.4"\napp = marimo.App(width="full")\n'
|
|
217
|
+
setup = 'with app.setup:\n' + '\n'.join(f' {l}' for l in setup_lines)
|
|
218
|
+
body = '\n\n\n'.join(cells)
|
|
219
|
+
footer = 'if __name__ == "__main__":\n app.run()'
|
|
220
|
+
|
|
221
|
+
content = '\n\n'.join([header, setup, body, footer]) + '\n'
|
|
222
|
+
|
|
223
|
+
out_path = Path(root) / (name or f"{meta['name'].replace('-', '_')}_bundled.py")
|
|
224
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
out_path.write_text(content)
|
|
226
|
+
return f"Bundled notebook to {out_path}"
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from .core import Kind, Param, Node, Config, read_config
|
|
2
|
+
from .read import scan, nb_name, read_meta
|
|
3
|
+
from .pkg import clean
|
|
4
|
+
from .build import bundle_notebook
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import ast, re, os
|
|
7
|
+
import marimo as mo
|
|
8
|
+
from functools import partial
|
|
9
|
+
from fastcore.xml import Span, Code, Li, Article, Div, Ul, P, FT, to_xml, Pre, Link, A, Iframe, Button, H1, H2, H3, Nav, Aside, Header, Input, NotStr, Strong, Main
|
|
10
|
+
from fasthtml.components import ft, Html, Head, Script, Body, show, Style, Title
|
|
11
|
+
|
|
12
|
+
icons = {'home': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house-icon lucide-house"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>', 'pypi': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-blocks-icon lucide-blocks"><path d="M10 22V7a1 1 0 0 0-1-1H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a1 1 0 0 0-1-1H2"/><rect x="14" y="2" width="8" height="8" rx="1"/></svg>', 'menu': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu-icon lucide-menu"><path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/></svg>', 'x': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>', 'github': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>', 'code': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-icon lucide-code"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>', 'info': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info-icon lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>', 'calendar': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar1-icon lucide-calendar-1"><path d="M11 14h1v4"/><path d="M16 2v4"/><path d="M3 10h18"/><path d="M8 2v4"/><rect x="3" y="4" width="18" height="18" rx="2"/></svg>', 'circle-x': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>', 'external-link': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-external-link-icon lucide-external-link"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'}
|
|
13
|
+
COLORS = {'keyword': '#c792ea', 'string': '#c3e88d', 'builtin': '#82aaff', 'decorator': '#f78c6c', 'comment': '#546e7a', 'number': '#f78c6c', 'punctuation': '#89ddff', 'defname': '#82aaff', 'classname': '#ffcb6b', 'operator': '#89ddff', 'call': '#82aaff', 'fstring': '#c3e88d', 'type': '#ffcb6b'}
|
|
14
|
+
|
|
15
|
+
def nb_path(
|
|
16
|
+
mod_name,
|
|
17
|
+
root='.'
|
|
18
|
+
):
|
|
19
|
+
'''[TODO] '''
|
|
20
|
+
cfg = read_config(root)
|
|
21
|
+
for f in (Path(root) / cfg.nbs).glob('*.py'):
|
|
22
|
+
if nb_name(f, root) == mod_name: return f.relative_to(root)
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
def render_node(n, repo_url=None, root='.'):
|
|
26
|
+
'''Builds a `node` for docs'''
|
|
27
|
+
t = 'class' if 'class ' in n.src else 'async' if n.src.lstrip().startswith('async ') else 'func'
|
|
28
|
+
signature = clean(n.src)
|
|
29
|
+
node_id = f"code-{n.module}-{n.name}"
|
|
30
|
+
nb = nb_path(n.module, root)
|
|
31
|
+
tag_colors = {'func': '#10b981', 'async': '#f59e0b', 'class': '#8b5cf6'}
|
|
32
|
+
|
|
33
|
+
code_lines = [Span(line, cls="code-line") for line in signature.split('\n')]
|
|
34
|
+
|
|
35
|
+
def ghbtn(icon, label, url):
|
|
36
|
+
if not url: return None
|
|
37
|
+
return A(Button(Icon(icon, size=16), label, cls="gh-btn"), href=url, target="_blank", cls="gh-link")
|
|
38
|
+
|
|
39
|
+
tag = Span(t, cls="tag", style=f"background:{tag_colors.get(t, '#666')};")
|
|
40
|
+
mod_name = Span(Span(f"{n.module}.", cls="mod"), Span(n.name, cls="name"), cls="full-name") if n.module else Span(n.name, cls="full-name name")
|
|
41
|
+
|
|
42
|
+
copy_btn = Button("📋", cls="copy-btn", onclick=f"navigator.clipboard.writeText(document.getElementById('{node_id}').textContent).then(() => this.textContent = '✓').then(() => setTimeout(() => this.textContent = '📋', 1500))")
|
|
43
|
+
source_btn = ghbtn('github', 'Source', f"{repo_url}/blob/master/{nb}#L{n.lineno}" if repo_url and nb and n.lineno else None)
|
|
44
|
+
edit_btn = ghbtn('code', 'Edit', f"{repo_url}/edit/master/{nb}" if repo_url and nb else None)
|
|
45
|
+
blame_btn = ghbtn('info', 'Blame', f"{repo_url}/blame/master/{nb}#L{n.lineno}" if repo_url and nb and n.lineno else None)
|
|
46
|
+
history_btn = ghbtn('calendar', 'History', f"{repo_url}/commits/master/{nb}" if repo_url and nb else None)
|
|
47
|
+
issue_btn = ghbtn('circle-x', 'Issue', f"{repo_url}/issues/new?title=Issue%20with%20{n.name}&body={repo_url}/blob/master/{nb}%23L{n.lineno}" if repo_url and nb and n.lineno else None)
|
|
48
|
+
|
|
49
|
+
return Article(
|
|
50
|
+
Style("""
|
|
51
|
+
me { margin-bottom: 0.75rem; border-radius: 8px; overflow: hidden; background: #1e1e1e; max-width: 100%; }
|
|
52
|
+
me .header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; }
|
|
53
|
+
me .header-left { display: flex; align-items: center; }
|
|
54
|
+
me .header-right { display: flex; align-items: center; gap: 0.5rem; }
|
|
55
|
+
me .tag { padding: 0.25rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; color: white; }
|
|
56
|
+
me .full-name { font-weight: 500; font-size: 1rem; margin-left: 0.75rem; }
|
|
57
|
+
me .mod { color: #666; }
|
|
58
|
+
me .name { color: #e5e5e5; }
|
|
59
|
+
me .doc { margin: 0; padding: 0 1rem 0.5rem 1rem; color: #888; font-size: 0.85rem; }
|
|
60
|
+
me .gh-btn { display: flex; align-items: center; gap: 0.25rem; background: #333; border: 1px solid #444; color: #ccc; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
|
|
61
|
+
me .gh-link { text-decoration: none; }
|
|
62
|
+
me .copy-btn { background: transparent; border: none; cursor: pointer; font-size: 0.9rem; padding: 0.25rem; }
|
|
63
|
+
me pre { background: #1a1a1a; border-top: 1px solid #2a2a2a; margin: 0; padding: 0.5rem 0; }
|
|
64
|
+
me code { counter-reset: line; font-size: 0.8rem; line-height: 1.6; color: #ccc; }
|
|
65
|
+
me .code-line { display: block; padding-left: 3.5em; white-space: pre-wrap; word-break: break-all; position: relative; }
|
|
66
|
+
me .code-line::before { content: counter(line); counter-increment: line; position: absolute; left: 0; width: 2.5em; text-align: right; color: #555; user-select: none; }
|
|
67
|
+
"""),
|
|
68
|
+
Div(Div(tag, mod_name, cls="header-left"),
|
|
69
|
+
Div(copy_btn, source_btn, edit_btn, blame_btn, history_btn, issue_btn, cls="header-right"),
|
|
70
|
+
cls="header"),
|
|
71
|
+
P(n.doc, cls="doc") if n.doc else None,
|
|
72
|
+
Pre(Code(*code_lines, cls="language-python", id=node_id)))
|
|
73
|
+
|
|
74
|
+
def render_module_page(mod_name, mod_nodes, all_mod_names, meta, root='.'):
|
|
75
|
+
'''Builds a Module Page'''
|
|
76
|
+
repo_url = meta.get('urls', {}).get('Repository')
|
|
77
|
+
exp_nodes = [n for n in mod_nodes if n.kind == Kind.EXP]
|
|
78
|
+
|
|
79
|
+
head_elements = [
|
|
80
|
+
Script(type="module", src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.8/bundles/datastar.js"),
|
|
81
|
+
Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js"),
|
|
82
|
+
Script(src="js/highlight.js", type="module"),
|
|
83
|
+
Style("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; } code { font-family: 'SF Mono', Consolas, monospace; }"),
|
|
84
|
+
Title(f"{mod_name} - {meta['name']}")]
|
|
85
|
+
|
|
86
|
+
nav_links = [Li(A("← Index", href="index.html", cls="back-link"))] + [
|
|
87
|
+
Li(A(m, href=f"{m}.html", cls=f"nav-link {'active' if m == mod_name else ''}")) for m in all_mod_names]
|
|
88
|
+
|
|
89
|
+
nav = Nav(
|
|
90
|
+
Style("""
|
|
91
|
+
me { padding: 1rem; background: #1a1a1a; min-width: 180px; }
|
|
92
|
+
me h3 { margin: 0 0 1rem 0; color: #fff; }
|
|
93
|
+
me ul { list-style: none; padding: 0; margin: 0; }
|
|
94
|
+
me .back-link { color: #888; text-decoration: none; font-size: 0.85rem; }
|
|
95
|
+
me .back-link:hover { color: #fff; }
|
|
96
|
+
me .nav-link { color: #aaa; text-decoration: none; }
|
|
97
|
+
me .nav-link:hover { color: #fff; }
|
|
98
|
+
me .nav-link.active { color: #fff; }
|
|
99
|
+
me input { width: 100%; padding: 0.5rem; border: 1px solid #333; border-radius: 4px; background: #252525; color: #fff; margin-bottom: 1rem; box-sizing: border-box; }
|
|
100
|
+
"""),
|
|
101
|
+
H3(meta['name']),
|
|
102
|
+
Input(type="text", placeholder="Search...", **{"data-bind": "search"}),
|
|
103
|
+
Ul(*nav_links))
|
|
104
|
+
|
|
105
|
+
header = Header(
|
|
106
|
+
Style("""
|
|
107
|
+
me { padding: 1rem; background: #1e1e1e; border-bottom: 1px solid #333; }
|
|
108
|
+
me .header-row { display: flex; align-items: center; justify-content: space-between; }
|
|
109
|
+
me h1 { margin: 0; font-size: 1.5rem; color: #fff; }
|
|
110
|
+
me .wasm-btn { display: flex; align-items: center; gap: 0.25rem; background: #333; border: 1px solid #444; color: #ccc; padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
|
|
111
|
+
me .wasm-link { text-decoration: none; }
|
|
112
|
+
"""),
|
|
113
|
+
Div(H1(mod_name),
|
|
114
|
+
A(Button(Icon('external-link', size=16), "Run in Browser", cls="wasm-btn"),
|
|
115
|
+
href=f"wasm/{mod_name}/index.html", target="_blank", cls="wasm-link"),
|
|
116
|
+
cls="header-row"))
|
|
117
|
+
|
|
118
|
+
content = Div(
|
|
119
|
+
Style("""
|
|
120
|
+
me { padding: 1rem; background: #121212; overflow-y: auto; flex: 1; }
|
|
121
|
+
"""),
|
|
122
|
+
*[render_node(n, repo_url, root) for n in exp_nodes])
|
|
123
|
+
|
|
124
|
+
body = Body(nav,
|
|
125
|
+
Div(header, content, style="flex: 1; display: flex; flex-direction: column;"),
|
|
126
|
+
style="display: flex; height: 100vh; margin: 0; background: #121212;",
|
|
127
|
+
**{"data-signals": "{search: ''}"})
|
|
128
|
+
|
|
129
|
+
return Html(Head(*head_elements), body)
|
|
130
|
+
|
|
131
|
+
def build_docs(
|
|
132
|
+
root='.' # the project root (this should never really change)
|
|
133
|
+
):
|
|
134
|
+
'''Builds the static documentation website'''
|
|
135
|
+
cfg = read_config(root)
|
|
136
|
+
meta = read_meta(root)
|
|
137
|
+
_, mods = scan(root)
|
|
138
|
+
mod_names = [name for name, _ in mods]
|
|
139
|
+
docs_path = Path(root) / cfg.docs
|
|
140
|
+
docs_path.mkdir(exist_ok=True)
|
|
141
|
+
(docs_path / "index.html").write_text(to_xml(render_index_page(meta, mods)))
|
|
142
|
+
for mod_name, mod_nodes in mods:
|
|
143
|
+
(docs_path / f"{mod_name}.html").write_text(to_xml(render_module_page(mod_name, mod_nodes, mod_names, meta, root)))
|
|
144
|
+
export_wasm(root)
|
|
145
|
+
return f"Generated index + {len(mods)} module pages in {docs_path}"
|
|
146
|
+
|
|
147
|
+
def export_wasm(
|
|
148
|
+
root='.' # Project Root
|
|
149
|
+
):
|
|
150
|
+
"""Uses the bundeled notebook to make a WASM marimo notebook"""
|
|
151
|
+
cfg = read_config(root)
|
|
152
|
+
wasm_dir = Path(root) / cfg.docs / 'wasm'
|
|
153
|
+
wasm_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
bundled = Path(root) / cfg.nbs / '_bundled.py'
|
|
155
|
+
bundle_notebook(root, name=str(bundled))
|
|
156
|
+
os.system(f"marimo export html-wasm {bundled} -o {wasm_dir} --mode edit")
|
|
157
|
+
bundled.unlink()
|
|
158
|
+
|
|
159
|
+
def write_nojekyll(root='.'):
|
|
160
|
+
cfg = read_config(root)
|
|
161
|
+
Path(root, cfg.docs, '.nojekyll').touch()
|
|
162
|
+
|
|
163
|
+
def html_preview(width='100%', height='300px'):
|
|
164
|
+
"Display FT components in an IFrame"
|
|
165
|
+
def _preview(*components): show(Iframe(srcdoc=to_xml(components[0] if len(components) == 1 else Div(*components)), width=width, height=height))
|
|
166
|
+
return _preview
|
|
167
|
+
|
|
168
|
+
def render_index_page(meta, mods, repo_url=None):
|
|
169
|
+
mod_names = [name for name, _ in mods]
|
|
170
|
+
head_elements = [
|
|
171
|
+
Script(type="module", src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js"),
|
|
172
|
+
Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js"),
|
|
173
|
+
Script(src="js/highlight.js", type="module"),
|
|
174
|
+
Style("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; } code { font-family: 'SF Mono', Consolas, monospace; }"),
|
|
175
|
+
Title(meta['name'])]
|
|
176
|
+
|
|
177
|
+
nav_links = [Li(A(m, href=f"{m}.html", cls="nav-link")) for m in mod_names]
|
|
178
|
+
|
|
179
|
+
nav = Nav(
|
|
180
|
+
Style("""
|
|
181
|
+
me { padding: 1rem; background: #1a1a1a; min-width: 180px; }
|
|
182
|
+
me h3 { margin: 0 0 1rem 0; color: #fff; }
|
|
183
|
+
me ul { list-style: none; padding: 0; margin: 0; }
|
|
184
|
+
me .nav-link { color: #aaa; text-decoration: none; }
|
|
185
|
+
me .nav-link:hover { color: #fff; }
|
|
186
|
+
"""),
|
|
187
|
+
H3(meta['name']),
|
|
188
|
+
Ul(*nav_links))
|
|
189
|
+
|
|
190
|
+
module_cards = [A(
|
|
191
|
+
Div(H3(name, cls="card-title"),
|
|
192
|
+
P(f"{len([n for n in nodes if n.kind == Kind.EXP])} exports", cls="card-count"),
|
|
193
|
+
cls="card"),
|
|
194
|
+
href=f"{name}.html", cls="card-link")
|
|
195
|
+
for name, nodes in mods]
|
|
196
|
+
|
|
197
|
+
content = Div(
|
|
198
|
+
Style("""
|
|
199
|
+
me { padding: 2rem; flex: 1; }
|
|
200
|
+
me h1 { margin: 0 0 0.5rem 0; color: #fff; }
|
|
201
|
+
me .desc { color: #888; margin: 0 0 2rem 0; }
|
|
202
|
+
me .version { color: #666; margin: 0 0 2rem 0; font-size: 0.9rem; }
|
|
203
|
+
me h2 { color: #fff; margin: 0 0 1rem 0; font-size: 1.2rem; }
|
|
204
|
+
me .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
|
205
|
+
me .card-link { text-decoration: none; }
|
|
206
|
+
me .card { padding: 1rem; background: #1e1e1e; border-radius: 8px; }
|
|
207
|
+
me .card:hover { background: #252525; }
|
|
208
|
+
me .card-title { margin: 0 0 0.5rem 0; color: #fff; }
|
|
209
|
+
me .card-count { margin: 0; color: #888; }
|
|
210
|
+
"""),
|
|
211
|
+
H1(meta['name']),
|
|
212
|
+
P(meta['desc'], cls="desc"),
|
|
213
|
+
P(f"Version {meta['version']}", cls="version"),
|
|
214
|
+
H2("Modules"),
|
|
215
|
+
Div(*module_cards, cls="grid"))
|
|
216
|
+
|
|
217
|
+
body = Body(nav, content, style="display: flex; min-height: 100vh; margin: 0; background: #121212;")
|
|
218
|
+
return Html(Head(*head_elements), body)
|
|
219
|
+
|
|
220
|
+
def Icon(name: str, # name of the icon MUST be in icon_dict
|
|
221
|
+
size=24, # value to be passed to height and width of the icon
|
|
222
|
+
stroke=1.5, # stroke width
|
|
223
|
+
cls=None, # css class
|
|
224
|
+
icon_dict:dict=icons, # Dict of icons {"name":"<svg...>"}
|
|
225
|
+
**kwargs # passed to through to FT
|
|
226
|
+
) -> 'Any': # Follow recomendation from fastHTML docs
|
|
227
|
+
'''
|
|
228
|
+
Creates a custom html compliant <icon-{name}>...
|
|
229
|
+
Intended to be used with a Global Dict of icons {"home": "<svg...", "info": "<svg..."}
|
|
230
|
+
Icon('home') -> <icon-home> .... </icon-home>
|
|
231
|
+
'''
|
|
232
|
+
if name not in icon_dict: raise ValueError(f"Icon '{name}' not found")
|
|
233
|
+
|
|
234
|
+
# count=1 Replace only the first occurrence of width & height 99% of time this is what you want
|
|
235
|
+
svg_string = icon_dict[name]
|
|
236
|
+
svg_string = re.sub(r'width="\d+"', f'width="{size}"', svg_string, count=1)
|
|
237
|
+
svg_string = re.sub(r'height="\d+"', f'height="{size}"', svg_string, count=1)
|
|
238
|
+
svg_string = re.sub(r'stroke-width="\d+"', f'stroke-width="{stroke}"', svg_string)
|
|
239
|
+
|
|
240
|
+
return ft(f'icon-{name}', NotStr(svg_string), cls=cls, **kwargs)
|
|
241
|
+
|
|
242
|
+
def write_highlighter(root='.'):
|
|
243
|
+
"Write Python syntax highlighter JS to docs folder."
|
|
244
|
+
cfg = read_config(root)
|
|
245
|
+
js_dir = Path(root) / cfg.docs / 'js'
|
|
246
|
+
js_dir.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
|
|
248
|
+
tokens = [
|
|
249
|
+
('py-decorator', r'@[\w.]+'),
|
|
250
|
+
('py-defname', r'(?<=def )\w+'),
|
|
251
|
+
('py-classname', r'(?<=class )\w+'),
|
|
252
|
+
('py-keyword', r'\b(?:def|class|return|if|elif|else|for|while|import|from|as|with|try|except|finally|raise|yield|async|await|pass|break|continue|in|is|not|and|or|lambda|None|True|False|self)\b'),
|
|
253
|
+
('py-builtin', r'\b(?:print|len|range|list|dict|set|tuple|int|str|float|bool|isinstance|hasattr|getattr|setattr|enumerate|zip|map|filter|any|all|sorted|reversed|super|type|open|next)\b'),
|
|
254
|
+
('py-call', r'\b\w+(?=\()'),
|
|
255
|
+
('py-number', r'\b\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?\b'),
|
|
256
|
+
('py-operator', r'->|==|!=|<=|>=|\*\*|\/\/|[+\-*/%=<>&|^~]'),
|
|
257
|
+
('py-punctuation', r'[{}\(\)\[\]:,;]'),
|
|
258
|
+
('py-fstring', r"f(?='|\")"),
|
|
259
|
+
('py-type', r'(?<=: )\w+'),
|
|
260
|
+
('py-string', r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|"[^"\n]*"|\'[^\'\n]*\''),
|
|
261
|
+
('py-comment', r'#.*'),
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
token_js = ',\n '.join(f"['{name}', /{pattern}/g]" for name, pattern in tokens)
|
|
266
|
+
color_css = '\n'.join(f'::highlight({name}) {{ color: {COLORS[name.replace("py-", "")]}; }}' for name, _ in tokens)
|
|
267
|
+
|
|
268
|
+
js = f"""const PY_TOKENS = [
|
|
269
|
+
{token_js}
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
function highlightAll(root = document) {{
|
|
273
|
+
if (!CSS?.highlights) return;
|
|
274
|
+
for (const [name] of PY_TOKENS) CSS.highlights.delete(name);
|
|
275
|
+
root.querySelectorAll('pre code').forEach(code => {{
|
|
276
|
+
const textNodes = [];
|
|
277
|
+
code.querySelectorAll('.code-line').forEach(line => {{
|
|
278
|
+
if (line.firstChild?.nodeType === Node.TEXT_NODE) textNodes.push(line.firstChild);
|
|
279
|
+
}});
|
|
280
|
+
if (!textNodes.length) return;
|
|
281
|
+
|
|
282
|
+
for (const [name, pattern] of PY_TOKENS) {{
|
|
283
|
+
const highlight = CSS.highlights.get(name) ?? new Highlight();
|
|
284
|
+
for (const node of textNodes) {{
|
|
285
|
+
const src = node.textContent;
|
|
286
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
287
|
+
for (const match of src.matchAll(re)) {{
|
|
288
|
+
const range = new Range();
|
|
289
|
+
range.setStart(node, match.index);
|
|
290
|
+
range.setEnd(node, match.index + match[0].length);
|
|
291
|
+
highlight.add(range);
|
|
292
|
+
}}
|
|
293
|
+
}}
|
|
294
|
+
if (highlight.size > 0) CSS.highlights.set(name, highlight);
|
|
295
|
+
}}
|
|
296
|
+
}});
|
|
297
|
+
}}
|
|
298
|
+
|
|
299
|
+
// Inject highlight styles
|
|
300
|
+
const style = document.createElement('style');
|
|
301
|
+
style.textContent = `{color_css}`;
|
|
302
|
+
document.head.appendChild(style);
|
|
303
|
+
|
|
304
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => highlightAll());
|
|
305
|
+
else highlightAll();
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
(js_dir / 'highlight.js').write_text(js)
|
|
310
|
+
return f"Wrote {js_dir}/highlight.js"
|
|
@@ -23,7 +23,7 @@ def write_mod(
|
|
|
23
23
|
"Write module file with imports, constants, and exports."
|
|
24
24
|
g = {k: [n for n in nodes if n.kind == k] for k in Kind}
|
|
25
25
|
imports = '\n'.join(rewrite_imports(n.src, mod_names) for n in g[Kind.IMP])
|
|
26
|
-
parts = [imports, '\n'.join(n.src for n in g[Kind.CONST]), '\n\n'.join(clean(n.src) for n in g[Kind.EXP])]
|
|
26
|
+
parts = [imports, '\n'.join(n.src for n in g[Kind.CONST]), '\n'.join(n.src for n in g[Kind.SETUP]), '\n\n'.join(clean(n.src) for n in g[Kind.EXP])]
|
|
27
27
|
write(path, *parts)
|
|
28
28
|
|
|
29
29
|
def rewrite_imports(
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import subprocess, configparser, shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .build import build
|
|
4
|
+
|
|
5
|
+
CREDENTIALS = {'testpypi': {'file': '~/.pypirc', 'section': 'testpypi', 'key_field': 'password', 'prefix': 'pypi-', 'url': 'https://test.pypi.org/manage/account/'}, 'pypi': {'file': '~/.pypirc', 'section': 'pypi', 'key_field': 'password', 'prefix': 'pypi-', 'url': 'https://pypi.org/manage/account/'}, 'github': {'cmd': 'gh auth status', 'url': 'https://github.com/settings/tokens'}}
|
|
6
|
+
|
|
7
|
+
def check_credentials(platform='testpypi'):
|
|
8
|
+
"Check if credentials exist for a platform and return status info."
|
|
9
|
+
cred = CREDENTIALS.get(platform)
|
|
10
|
+
if not cred: return {'found': False, 'msg': f"Unknown platform: {platform}"}
|
|
11
|
+
|
|
12
|
+
url = cred['url']
|
|
13
|
+
|
|
14
|
+
# Command-based check (e.g. GitHub)
|
|
15
|
+
if 'cmd' in cred:
|
|
16
|
+
try:
|
|
17
|
+
r = subprocess.run(cred['cmd'].split(), capture_output=True, text=True)
|
|
18
|
+
if r.returncode == 0:
|
|
19
|
+
return {'found': True, 'platform': platform, 'msg': r.stdout.strip(), 'source': cred['cmd']}
|
|
20
|
+
return {'found': False, 'msg': f"Not authenticated. Set up at {url}"}
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
return {'found': False, 'msg': f"CLI not installed. Get it at {url}"}
|
|
23
|
+
|
|
24
|
+
# File-based check (e.g. PyPI)
|
|
25
|
+
path = Path(cred['file']).expanduser()
|
|
26
|
+
if not path.exists():
|
|
27
|
+
return {'found': False, 'msg': f"No {cred['file']} found. Create a token at {url}"}
|
|
28
|
+
|
|
29
|
+
config = configparser.ConfigParser()
|
|
30
|
+
config.read(path)
|
|
31
|
+
section = cred['section']
|
|
32
|
+
|
|
33
|
+
if section not in config:
|
|
34
|
+
return {'found': False, 'msg': f"No [{section}] section in {cred['file']}. Create a token at {url}"}
|
|
35
|
+
|
|
36
|
+
password = config[section].get(cred['key_field'], '')
|
|
37
|
+
if not password:
|
|
38
|
+
return {'found': False, 'msg': f"No {cred['key_field']} in [{section}]. Create a token at {url}"}
|
|
39
|
+
|
|
40
|
+
prefix = cred.get('prefix', '')
|
|
41
|
+
preview = password[:len(prefix)+4] + '...' if password.startswith(prefix) else '****'
|
|
42
|
+
return {'found': True, 'platform': platform, 'username': config[section].get('username', ''), 'preview': preview, 'source': str(path)}
|
|
43
|
+
|
|
44
|
+
def check_pypi_auth(test=True):
|
|
45
|
+
"Check if PyPI credentials exist and return status info."
|
|
46
|
+
pypirc = Path.home() / '.pypirc'
|
|
47
|
+
section = 'testpypi' if test else 'pypi'
|
|
48
|
+
target = 'Test PyPI' if test else 'PyPI'
|
|
49
|
+
url = 'https://test.pypi.org/manage/account/' if test else 'https://pypi.org/manage/account/'
|
|
50
|
+
|
|
51
|
+
if not pypirc.exists():
|
|
52
|
+
return {'found': False, 'msg': f"No ~/.pypirc found. Create a token at {url}"}
|
|
53
|
+
|
|
54
|
+
config = configparser.ConfigParser()
|
|
55
|
+
config.read(pypirc)
|
|
56
|
+
|
|
57
|
+
if section not in config:
|
|
58
|
+
return {'found': False, 'msg': f"No [{section}] section in ~/.pypirc. Create a token at {url}"}
|
|
59
|
+
|
|
60
|
+
password = config[section].get('password', '')
|
|
61
|
+
if not password:
|
|
62
|
+
return {'found': False, 'msg': f"No password in [{section}]. Create a token at {url}"}
|
|
63
|
+
|
|
64
|
+
preview = password[:9] + '...' if password.startswith('pypi-') else '****'
|
|
65
|
+
return {'found': True, 'section': section, 'username': config[section].get('username', ''), 'preview': preview, 'source': str(pypirc)}
|
|
66
|
+
|
|
67
|
+
def publish(
|
|
68
|
+
test:bool=True, # Use Test PyPI if True, real PyPI if False
|
|
69
|
+
):
|
|
70
|
+
"Build and publish package to PyPI. Looks for ~/.pypirc for credentials, otherwise prompts."
|
|
71
|
+
|
|
72
|
+
print("Rebuilding package from notebooks...")
|
|
73
|
+
build()
|
|
74
|
+
|
|
75
|
+
shutil.rmtree('dist', ignore_errors=True)
|
|
76
|
+
print("Building distribution...")
|
|
77
|
+
subprocess.run(['uv', 'build'], check=True)
|
|
78
|
+
|
|
79
|
+
pypirc, cmd = Path.home() / '.pypirc', ['uv', 'publish']
|
|
80
|
+
section = 'testpypi' if test else 'pypi'
|
|
81
|
+
|
|
82
|
+
if test: cmd.extend(['--publish-url', 'https://test.pypi.org/legacy/'])
|
|
83
|
+
else: cmd.extend(['--publish-url', 'https://upload.pypi.org/legacy/'])
|
|
84
|
+
|
|
85
|
+
if pypirc.exists():
|
|
86
|
+
config = configparser.ConfigParser()
|
|
87
|
+
config.read(pypirc)
|
|
88
|
+
if section in config:
|
|
89
|
+
username, password = config[section].get('username', '__token__'), config[section].get('password', '')
|
|
90
|
+
cmd.extend(['--username', username, '--password', password])
|
|
91
|
+
|
|
92
|
+
print(f"Publishing to {'Test ' if test else ''}PyPI...")
|
|
93
|
+
subprocess.run(cmd, check=True)
|
|
@@ -123,8 +123,11 @@ def parse_node(
|
|
|
123
123
|
if isinstance(n, ast.With):
|
|
124
124
|
for s in n.body:
|
|
125
125
|
if (node := parse_import(s, ls)): yield node
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
elif (node := parse_const(s, ls)): yield node
|
|
127
|
+
else: yield Node(Kind.SETUP,
|
|
128
|
+
getattr(s, 'name', '_setup'),
|
|
129
|
+
ast.get_source_segment(src, s) or ast.unparse(s)
|
|
130
|
+
)
|
|
128
131
|
# Handle export-named cells (e.g. def export(): or def export_main():)
|
|
129
132
|
if isinstance(n, ast.FunctionDef) and n.name.startswith('export'):
|
|
130
133
|
# Check it's decorated with @app.cell, not @app.function
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
from .core import Kind, Param, Node, Config, read_config
|
|
2
|
-
from .read import scan, read_meta
|
|
3
|
-
from .pkg import write_mod, write_init, clean
|
|
4
|
-
from .docs import write_llms
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
import ast, shutil, re, sys
|
|
7
|
-
|
|
8
|
-
IMPORT_TO_PYPI = {'bs4': 'beautifulsoup4', 'PIL': 'pillow', 'cv2': 'opencv-python', 'sklearn': 'scikit-learn', 'yaml': 'pyyaml'}
|
|
9
|
-
|
|
10
|
-
def build(
|
|
11
|
-
root='.', # root directory containing pyproject.toml
|
|
12
|
-
)->str: # path to built package
|
|
13
|
-
"Build a Python package from notebooks."
|
|
14
|
-
cfg = read_config(root)
|
|
15
|
-
meta, mods = scan(root)
|
|
16
|
-
mod_names = [name for name, _ in mods]
|
|
17
|
-
pkg = Path(root) / cfg.out / meta['name'].replace('-', '_')
|
|
18
|
-
if pkg.exists(): shutil.rmtree(pkg)
|
|
19
|
-
pkg.mkdir(parents=True, exist_ok=True)
|
|
20
|
-
for name, nodes in mods:
|
|
21
|
-
stripped = re.sub(r'^[a-z]_', '', name)
|
|
22
|
-
if stripped != 'index' and any(n.kind == Kind.EXP for n in nodes): write_mod(pkg/f'{stripped}.py', nodes, mod_names)
|
|
23
|
-
write_init(pkg/'__init__.py', meta, mods)
|
|
24
|
-
all_exp = [n for _, nodes in mods for n in nodes if n.kind == Kind.EXP]
|
|
25
|
-
if all_exp: write_llms(meta, all_exp)
|
|
26
|
-
return str(pkg)
|
|
27
|
-
|
|
28
|
-
def tidy():
|
|
29
|
-
"Remove cache and temporary files (__pycache__, __marimo__, .pytest_cache, etc)."
|
|
30
|
-
import shutil
|
|
31
|
-
for p in Path('.').rglob('__pycache__'): shutil.rmtree(p, ignore_errors=True)
|
|
32
|
-
for p in Path('.').rglob('__marimo__'): shutil.rmtree(p, ignore_errors=True)
|
|
33
|
-
for p in Path('.').rglob('.pytest_cache'): shutil.rmtree(p, ignore_errors=True)
|
|
34
|
-
for p in Path('.').rglob('*.pyc'): p.unlink(missing_ok=True)
|
|
35
|
-
print("Cleaned cache files")
|
|
36
|
-
|
|
37
|
-
def nuke():
|
|
38
|
-
"Remove all build artifacts (dist, docs, src) and cache files."
|
|
39
|
-
import shutil
|
|
40
|
-
tidy()
|
|
41
|
-
for d in ['dist', 'docs', 'src', 'temp']: shutil.rmtree(d, ignore_errors=True)
|
|
42
|
-
print("Nuked build artifacts")
|
|
43
|
-
|
|
44
|
-
def get_pypi_name(import_name):
|
|
45
|
-
"Map import name to PyPI package name."
|
|
46
|
-
root = import_name.split('.')[0]
|
|
47
|
-
return IMPORT_TO_PYPI.get(root, root)
|
|
48
|
-
|
|
49
|
-
def extract_import_names(nodes):
|
|
50
|
-
"Extract top-level module names from import nodes."
|
|
51
|
-
names = set()
|
|
52
|
-
for n in nodes:
|
|
53
|
-
if n.kind != Kind.IMP: continue
|
|
54
|
-
tree = ast.parse(n.src)
|
|
55
|
-
for stmt in ast.walk(tree):
|
|
56
|
-
if isinstance(stmt, ast.Import):
|
|
57
|
-
for alias in stmt.names:
|
|
58
|
-
names.add(alias.name.split('.')[0])
|
|
59
|
-
elif isinstance(stmt, ast.ImportFrom) and stmt.module:
|
|
60
|
-
names.add(stmt.module.split('.')[0])
|
|
61
|
-
return names
|
|
62
|
-
|
|
63
|
-
def pep723_header(deps):
|
|
64
|
-
"Generate PEP 723 inline script metadata."
|
|
65
|
-
deps_str = ', '.join(f'"{d}"' for d in sorted(deps))
|
|
66
|
-
return f'# /// script\n# dependencies = [{deps_str}]\n# ///\n'
|
|
67
|
-
|
|
68
|
-
def bundle(root='.', name=None):
|
|
69
|
-
"Bundle all notebooks into a single Python file with PEP 723 dependencies."
|
|
70
|
-
cfg = read_config(root)
|
|
71
|
-
meta, mods = scan(root)
|
|
72
|
-
|
|
73
|
-
# Get notebook filenames to filter out local imports
|
|
74
|
-
nbs_dir = Path(root) / cfg.nbs
|
|
75
|
-
nb_names = {f.stem for f in nbs_dir.glob('*.py')}
|
|
76
|
-
|
|
77
|
-
# Collect all nodes
|
|
78
|
-
all_nodes = [n for _, nodes in mods for n in nodes]
|
|
79
|
-
|
|
80
|
-
# Extract dependencies
|
|
81
|
-
import_names = extract_import_names(all_nodes)
|
|
82
|
-
mod_names = [m for m, _ in mods]
|
|
83
|
-
|
|
84
|
-
# Filter out local modules, notebook names, and stdlib
|
|
85
|
-
external = {get_pypi_name(n) for n in import_names
|
|
86
|
-
if n not in mod_names
|
|
87
|
-
and n not in nb_names
|
|
88
|
-
and n not in sys.stdlib_module_names}
|
|
89
|
-
|
|
90
|
-
# Build output
|
|
91
|
-
header = pep723_header(external)
|
|
92
|
-
|
|
93
|
-
# Filter out local imports, dedupe externals
|
|
94
|
-
seen = set()
|
|
95
|
-
filtered_imports = []
|
|
96
|
-
for n in all_nodes:
|
|
97
|
-
if n.kind != Kind.IMP: continue
|
|
98
|
-
# Skip if it's a local notebook import
|
|
99
|
-
if any(nb in n.src for nb in nb_names): continue
|
|
100
|
-
if n.src not in seen:
|
|
101
|
-
seen.add(n.src)
|
|
102
|
-
filtered_imports.append(n.src)
|
|
103
|
-
|
|
104
|
-
imports = '\n'.join(filtered_imports)
|
|
105
|
-
|
|
106
|
-
consts = '\n'.join(n.src for n in all_nodes if n.kind == Kind.CONST)
|
|
107
|
-
exports = '\n\n'.join(clean(n.src) for n in all_nodes if n.kind == Kind.EXP)
|
|
108
|
-
|
|
109
|
-
content = '\n\n'.join(p for p in [header, imports, consts, exports] if p.strip())
|
|
110
|
-
|
|
111
|
-
# Determine output path
|
|
112
|
-
if name:
|
|
113
|
-
out_path = Path(root) / name
|
|
114
|
-
else:
|
|
115
|
-
out_path = Path(root) / cfg.out / meta['name'].replace('-', '_') / '__init__.py'
|
|
116
|
-
|
|
117
|
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
-
out_path.write_text(content)
|
|
119
|
-
return f"Bundled to {out_path}"
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
from .core import Kind, Param, Node, Config, read_config
|
|
2
|
-
from .read import scan, nb_name, read_meta
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
import ast, re, os
|
|
5
|
-
import marimo as mo
|
|
6
|
-
from functools import partial
|
|
7
|
-
from fastcore.xml import Span, Code, Li, Article, Div, Ul, P, FT, to_xml, Pre, Link, A, Iframe, Button, H1, H2, H3, Nav, Aside, Header, Input, NotStr, Strong, Main
|
|
8
|
-
from fasthtml.components import ft, Html, Head, Script, Body, show, Style, Title
|
|
9
|
-
|
|
10
|
-
icons = {'home': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house-icon lucide-house"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>', 'pypi': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-blocks-icon lucide-blocks"><path d="M10 22V7a1 1 0 0 0-1-1H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a1 1 0 0 0-1-1H2"/><rect x="14" y="2" width="8" height="8" rx="1"/></svg>', 'menu': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu-icon lucide-menu"><path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/></svg>', 'x': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>', 'github': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>', 'code': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code-icon lucide-code"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>', 'info': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info-icon lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>', 'calendar': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar1-icon lucide-calendar-1"><path d="M11 14h1v4"/><path d="M16 2v4"/><path d="M3 10h18"/><path d="M8 2v4"/><rect x="3" y="4" width="18" height="18" rx="2"/></svg>', 'circle-x': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>', 'external-link': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-external-link-icon lucide-external-link"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'}
|
|
11
|
-
|
|
12
|
-
def cls_sig(
|
|
13
|
-
n:Node, # the node to generate signature for
|
|
14
|
-
dataclass=False, # whether to include @dataclass decorator
|
|
15
|
-
)->str: # formatted class signature
|
|
16
|
-
"Generate a class signature string."
|
|
17
|
-
header = f"@dataclass\nclass {n.name}:" if dataclass else f"class {n.name}:"
|
|
18
|
-
lines = [header]
|
|
19
|
-
if n.doc: lines.append(f' """{n.doc}"""')
|
|
20
|
-
for p in n.params:
|
|
21
|
-
attr = f" {p.name}{f': {p.anno}' if p.anno else ''}{f' = {p.default}' if p.default else ''}"
|
|
22
|
-
if p.doc: attr += f" # {p.doc}"
|
|
23
|
-
lines.append(attr)
|
|
24
|
-
for m in n.methods:
|
|
25
|
-
ps = ', '.join(f"{p.name}{f': {p.anno}' if p.anno else ''}{f'={p.default}' if p.default else ''}" for p in m['params'])
|
|
26
|
-
ret = f" -> {m['ret'][0]}" if m['ret'] else ""
|
|
27
|
-
lines.append(f" def {m['name']}({ps}){ret}:")
|
|
28
|
-
if m['doc']: lines.append(f' """{m["doc"]}"""')
|
|
29
|
-
return '\n'.join(lines)
|
|
30
|
-
|
|
31
|
-
def fn_sig(
|
|
32
|
-
n: Node, # the node to generate signature for
|
|
33
|
-
is_async=False # async ?
|
|
34
|
-
)->str: # formatted signature string
|
|
35
|
-
"Generate a function signature string with inline parameter documentation."
|
|
36
|
-
prefix = 'async def' if is_async else 'def'
|
|
37
|
-
ret = f" -> {n.ret[0]}" if n.ret else ""
|
|
38
|
-
if not n.params:
|
|
39
|
-
sig = f"{prefix} {n.name}(){ret}:"
|
|
40
|
-
return f'{sig}\n """{n.doc}"""' if n.doc else sig
|
|
41
|
-
params = [f" {p.name}{f': {p.anno}' if p.anno else ''}{f'={p.default}' if p.default else ''},{f' # {p.doc}' if p.doc else ''}" for p in n.params]
|
|
42
|
-
params[-1] = params[-1].replace(',', '')
|
|
43
|
-
lines = [f"{prefix} {n.name}("] + params + [f"){ret}:"]
|
|
44
|
-
if n.doc: lines.append(f' """{n.doc}"""')
|
|
45
|
-
return '\n'.join(lines)
|
|
46
|
-
|
|
47
|
-
def sig(
|
|
48
|
-
n:Node, # the node to generate signature for
|
|
49
|
-
)->str: # formatted signature string
|
|
50
|
-
"Generate a signature string for a class or function node."
|
|
51
|
-
t = exp_type(n)
|
|
52
|
-
if t == 'class': return cls_sig(n, dataclass=n.src.lstrip().startswith('@dataclass'))
|
|
53
|
-
return fn_sig(n, is_async=t == 'async')
|
|
54
|
-
|
|
55
|
-
def write_llms(
|
|
56
|
-
meta: dict, # project metadata from pyproject.toml
|
|
57
|
-
nodes: list, # list of Node objects to document
|
|
58
|
-
root: str='.' # root directory containing pyproject.toml
|
|
59
|
-
):
|
|
60
|
-
"Write API signatures to llms.txt file for LLM consumption."
|
|
61
|
-
cfg = read_config(root)
|
|
62
|
-
sigs = '\n\n'.join(sig(n) for n in nodes if not n.name.startswith('__') and 'nodoc' not in n.hash_pipes)
|
|
63
|
-
content = f"# {meta['name']}\n\n> {meta['desc']}\n\nVersion: {meta['version']}\n\n## API\n\n```python\n{sigs}\n```"
|
|
64
|
-
Path(cfg.docs).mkdir(exist_ok=True)
|
|
65
|
-
(Path(cfg.docs)/'llms.txt').write_text(content)
|
|
66
|
-
|
|
67
|
-
def exp_type(n):
|
|
68
|
-
if n.methods or 'class ' in n.src: return 'class'
|
|
69
|
-
if n.src.lstrip().startswith('async def'): return 'async'
|
|
70
|
-
return 'func'
|
|
71
|
-
|
|
72
|
-
def render_param(p):
|
|
73
|
-
parts = [Code(p.name)]
|
|
74
|
-
if p.anno: parts.append(Span(f": {p.anno}", style="color: #666;"))
|
|
75
|
-
if p.default: parts.append(Span(f" = {p.default}", style="color: #888;"))
|
|
76
|
-
if p.doc: parts.append(Span(f" — {p.doc}", style="color: #555; font-style: italic;"))
|
|
77
|
-
return Li(*parts)
|
|
78
|
-
|
|
79
|
-
def nb_path(
|
|
80
|
-
mod_name,
|
|
81
|
-
root='.'
|
|
82
|
-
):
|
|
83
|
-
'''[TODO] '''
|
|
84
|
-
cfg = read_config(root)
|
|
85
|
-
for f in (Path(root) / cfg.nbs).glob('*.py'):
|
|
86
|
-
if nb_name(f, root) == mod_name: return f.relative_to(root)
|
|
87
|
-
return None
|
|
88
|
-
|
|
89
|
-
def render_node(
|
|
90
|
-
n,
|
|
91
|
-
repo_url=None,
|
|
92
|
-
root='.'
|
|
93
|
-
):
|
|
94
|
-
'''Builds a `node` for docs'''
|
|
95
|
-
t = exp_type(n)
|
|
96
|
-
signature = sig(n)
|
|
97
|
-
lines = signature.split('\n')
|
|
98
|
-
line_nums = '\n'.join(str(i+1) for i in range(len(lines)))
|
|
99
|
-
node_id = f"code-{n.module}-{n.name}"
|
|
100
|
-
tag_colors = {'func': '#10b981', 'async': '#f59e0b', 'class': '#8b5cf6'}
|
|
101
|
-
tag = Span(t, style=f"padding: 0.25rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; background: {tag_colors.get(t, '#666')}; color: white;")
|
|
102
|
-
full_name = Span(Span(f"{n.module}.", style="color: #666;"), Span(n.name, style="color: #e5e5e5;"), style="font-weight: 500; font-size: 1rem; margin-left: 0.75rem;") if n.module else Span(n.name, style="font-weight: 500; font-size: 1rem; color: #e5e5e5; margin-left: 0.75rem;")
|
|
103
|
-
nb = nb_path(n.module, root)
|
|
104
|
-
btn_style = "display: flex; align-items: center; gap: 0.25rem; background: #333; border: 1px solid #444; color: #ccc; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem;"
|
|
105
|
-
link_style = "text-decoration: none;"
|
|
106
|
-
copy_btn = Button("📋", onclick=f"navigator.clipboard.writeText(document.getElementById('{node_id}').textContent).then(() => this.textContent = '✓').then(() => setTimeout(() => this.textContent = '📋', 1500))", style="background: transparent; border: none; cursor: pointer; font-size: 0.9rem; padding: 0.25rem;")
|
|
107
|
-
source_btn = A(Button(Icon('github', size=16), "Source", style=btn_style), href=f"{repo_url}/blob/master/{nb}#L{n.lineno}", target="_blank", style=link_style) if repo_url and nb and n.lineno else None
|
|
108
|
-
edit_btn = A(Button(Icon('code', size=16), "Edit", style=btn_style), href=f"{repo_url}/edit/master/{nb}", target="_blank", style=link_style) if repo_url and nb else None
|
|
109
|
-
blame_btn = A(Button(Icon('info', size=16), "Blame", style=btn_style), href=f"{repo_url}/blame/master/{nb}#L{n.lineno}", target="_blank", style=link_style) if repo_url and nb and n.lineno else None
|
|
110
|
-
history_btn = A(Button(Icon('calendar', size=16), "History", style=btn_style), href=f"{repo_url}/commits/master/{nb}", target="_blank", style=link_style) if repo_url and nb else None
|
|
111
|
-
issue_btn = A(Button(Icon('circle-x', size=16), "Issue", style=btn_style), href=f"{repo_url}/issues/new?title=Issue%20with%20{n.name}&body={repo_url}/blob/master/{nb}%23L{n.lineno}", target="_blank", style=link_style) if repo_url and nb else None
|
|
112
|
-
|
|
113
|
-
header = Div(
|
|
114
|
-
Div(tag, full_name, style="display: flex; align-items: center;"),
|
|
115
|
-
Div(copy_btn, source_btn, edit_btn, blame_btn, history_btn, issue_btn, style="display: flex; align-items: center; gap: 0.5rem;"),
|
|
116
|
-
style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem;")
|
|
117
|
-
doc_line = P(n.doc, style="margin: 0; padding: 0 1rem 0.5rem 1rem; color: #888; font-size: 0.85rem;") if n.doc else None
|
|
118
|
-
code_block = Div(
|
|
119
|
-
Pre(Code(line_nums), style="margin: 0; padding: 0; color: #555; text-align: right; user-select: none; font-size: 0.8rem; line-height: 1.6;"),
|
|
120
|
-
Pre(Code(signature, cls="language-python", id=node_id), style="margin: 0; padding: 0; flex: 1; overflow-x: auto; font-size: 0.8rem; line-height: 1.6;"),
|
|
121
|
-
style="display: flex; background: #1a1a1a; border-top: 1px solid #2a2a2a;")
|
|
122
|
-
return Article(header, doc_line, code_block, style="margin-bottom: 0.75rem; border-radius: 8px; overflow: hidden; background: #1e1e1e;")
|
|
123
|
-
|
|
124
|
-
def render_module_page(
|
|
125
|
-
mod_name,
|
|
126
|
-
mod_nodes,
|
|
127
|
-
all_mod_names,
|
|
128
|
-
meta,
|
|
129
|
-
root='.'):
|
|
130
|
-
'''Builds a Module Page'''
|
|
131
|
-
repo_url = meta.get('urls', {}).get('Repository')
|
|
132
|
-
exp_nodes = [n for n in mod_nodes if n.kind == Kind.EXP]
|
|
133
|
-
content = Div(*[render_node(n, repo_url, root) for n in exp_nodes], style="padding: 1rem; background: #121212; overflow-y: auto;")
|
|
134
|
-
head_elements = [
|
|
135
|
-
Script(type="module", src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js"),
|
|
136
|
-
Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css"),
|
|
137
|
-
Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"),
|
|
138
|
-
Script("hljs.highlightAll();"),
|
|
139
|
-
Style("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; } code { font-family: 'SF Mono', Consolas, monospace; }"),
|
|
140
|
-
Title(f"{mod_name} - {meta['name']}")]
|
|
141
|
-
search_input = Input(type="text", placeholder="Search...", style="width: 100%; padding: 0.5rem; border: 1px solid #333; border-radius: 4px; background: #252525; color: #fff; margin-bottom: 1rem;", **{"data-bind": "search"})
|
|
142
|
-
nav_links = [Li(A("← Index", href="index.html", style="color: #888; text-decoration: none; font-size: 0.85rem;"))] + [Li(A(m, href=f"{m}.html", style=f"color: {'#fff' if m == mod_name else '#aaa'}; text-decoration: none;")) for m in all_mod_names]
|
|
143
|
-
nav = Nav(
|
|
144
|
-
H3(meta['name'], style="margin: 0 0 1rem 0; color: #fff;"),
|
|
145
|
-
search_input,
|
|
146
|
-
Ul(*nav_links, style="list-style: none; padding: 0; margin: 0;"),
|
|
147
|
-
style="padding: 1rem; background: #1a1a1a; min-width: 180px;")
|
|
148
|
-
btn_style = "display: flex; align-items: center; gap: 0.25rem; background: #333; border: 1px solid #444; color: #ccc; padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem;"
|
|
149
|
-
wasm_btn = A(Button(Icon('external-link', size=16), "Run in Browser", style=btn_style), href=f"wasm/{mod_name}/index.html", target="_blank", style="text-decoration: none;")
|
|
150
|
-
header = Header(
|
|
151
|
-
Div(H1(mod_name, style="margin: 0; font-size: 1.5rem; color: #fff;"), wasm_btn, style="display: flex; align-items: center; justify-content: space-between;"),
|
|
152
|
-
style="padding: 1rem; background: #1e1e1e; border-bottom: 1px solid #333;")
|
|
153
|
-
body = Body(nav, Div(header, content, style="flex: 1; display: flex; flex-direction: column;"), style="display: flex; height: 100vh; margin: 0; background: #121212;", **{"data-signals": "{search: ''}"})
|
|
154
|
-
return Html(Head(*head_elements), body)
|
|
155
|
-
|
|
156
|
-
def build_docs(
|
|
157
|
-
root='.' # the project root (this should never really change)
|
|
158
|
-
):
|
|
159
|
-
'''Builds the static documentation website'''
|
|
160
|
-
cfg = read_config(root)
|
|
161
|
-
meta = read_meta(root)
|
|
162
|
-
_, mods = scan(root)
|
|
163
|
-
mod_names = [name for name, _ in mods]
|
|
164
|
-
docs_path = Path(root) / cfg.docs
|
|
165
|
-
docs_path.mkdir(exist_ok=True)
|
|
166
|
-
(docs_path / "index.html").write_text(to_xml(render_index_page(meta, mods)))
|
|
167
|
-
for mod_name, mod_nodes in mods:
|
|
168
|
-
(docs_path / f"{mod_name}.html").write_text(to_xml(render_module_page(mod_name, mod_nodes, mod_names, meta, root)))
|
|
169
|
-
export_wasm(root)
|
|
170
|
-
return f"Generated index + {len(mods)} module pages in {docs_path}"
|
|
171
|
-
|
|
172
|
-
def export_wasm(root='.'):
|
|
173
|
-
cfg = read_config(root)
|
|
174
|
-
nbs_dir = Path(root) / cfg.nbs
|
|
175
|
-
wasm_dir = Path(root) / cfg.docs / 'wasm'
|
|
176
|
-
wasm_dir.mkdir(parents=True, exist_ok=True)
|
|
177
|
-
for f in nbs_dir.glob('*.py'):
|
|
178
|
-
name = nb_name(f, root)
|
|
179
|
-
if name: os.system(f"marimo export html-wasm {f} -o {wasm_dir}/{name} --mode edit")
|
|
180
|
-
|
|
181
|
-
def write_nojekyll(root='.'):
|
|
182
|
-
cfg = read_config(root)
|
|
183
|
-
Path(root, cfg.docs, '.nojekyll').touch()
|
|
184
|
-
|
|
185
|
-
def html_preview(width='100%', height='300px'):
|
|
186
|
-
"Display FT components in an IFrame"
|
|
187
|
-
def _preview(*components): show(Iframe(srcdoc=to_xml(components[0] if len(components) == 1 else Div(*components)), width=width, height=height))
|
|
188
|
-
return _preview
|
|
189
|
-
|
|
190
|
-
def render_index_page(meta, mods, repo_url=None):
|
|
191
|
-
mod_names = [name for name, _ in mods]
|
|
192
|
-
head_elements = [
|
|
193
|
-
Script(type="module", src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js"),
|
|
194
|
-
Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css"),
|
|
195
|
-
Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"),
|
|
196
|
-
Script("hljs.highlightAll();"),
|
|
197
|
-
Style("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; } code { font-family: 'SF Mono', Consolas, monospace; }"),
|
|
198
|
-
Title(meta['name'])]
|
|
199
|
-
nav_links = [Li(A(m, href=f"{m}.html", style="color: #aaa; text-decoration: none;")) for m in mod_names]
|
|
200
|
-
nav = Nav(
|
|
201
|
-
H3(meta['name'], style="margin: 0 0 1rem 0; color: #fff;"),
|
|
202
|
-
Ul(*nav_links, style="list-style: none; padding: 0; margin: 0;"),
|
|
203
|
-
style="padding: 1rem; background: #1a1a1a; min-width: 180px;")
|
|
204
|
-
module_cards = [A(Div(H3(name, style="margin: 0 0 0.5rem 0; color: #fff;"), P(f"{len([n for n in nodes if n.kind == Kind.EXP])} exports", style="margin: 0; color: #888;"),
|
|
205
|
-
style="padding: 1rem; background: #1e1e1e; border-radius: 8px;"), href=f"{name}.html", style="text-decoration: none;") for name, nodes in mods]
|
|
206
|
-
content = Div(
|
|
207
|
-
H1(meta['name'], style="margin: 0 0 0.5rem 0; color: #fff;"),
|
|
208
|
-
P(meta['desc'], style="color: #888; margin: 0 0 2rem 0;"),
|
|
209
|
-
P(f"Version {meta['version']}", style="color: #666; margin: 0 0 2rem 0; font-size: 0.9rem;"),
|
|
210
|
-
H2("Modules", style="color: #fff; margin: 0 0 1rem 0; font-size: 1.2rem;"),
|
|
211
|
-
Div(*module_cards, style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem;"),
|
|
212
|
-
style="padding: 2rem; flex: 1;")
|
|
213
|
-
body = Body(nav, content, style="display: flex; min-height: 100vh; margin: 0; background: #121212;")
|
|
214
|
-
return Html(Head(*head_elements), body)
|
|
215
|
-
|
|
216
|
-
def Icon(name: str, # name of the icon MUST be in icon_dict
|
|
217
|
-
size=24, # value to be passed to height and width of the icon
|
|
218
|
-
stroke=1.5, # stroke width
|
|
219
|
-
cls=None, # css class
|
|
220
|
-
icon_dict:dict=icons, # Dict of icons {"name":"<svg...>"}
|
|
221
|
-
**kwargs # passed to through to FT
|
|
222
|
-
) -> 'Any': # Follow recomendation from fastHTML docs
|
|
223
|
-
'''
|
|
224
|
-
Creates a custom html compliant <icon-{name}>...
|
|
225
|
-
Intended to be used with a Global Dict of icons {"home": "<svg...", "info": "<svg..."}
|
|
226
|
-
Icon('home') -> <icon-home> .... </icon-home>
|
|
227
|
-
'''
|
|
228
|
-
if name not in icon_dict: raise ValueError(f"Icon '{name}' not found")
|
|
229
|
-
|
|
230
|
-
# count=1 Replace only the first occurrence of width & height 99% of time this is what you want
|
|
231
|
-
svg_string = icon_dict[name]
|
|
232
|
-
svg_string = re.sub(r'width="\d+"', f'width="{size}"', svg_string, count=1)
|
|
233
|
-
svg_string = re.sub(r'height="\d+"', f'height="{size}"', svg_string, count=1)
|
|
234
|
-
svg_string = re.sub(r'stroke-width="\d+"', f'stroke-width="{stroke}"', svg_string)
|
|
235
|
-
|
|
236
|
-
return ft(f'icon-{name}', NotStr(svg_string), cls=cls, **kwargs)
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import subprocess, configparser, shutil
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from .build import build
|
|
4
|
-
|
|
5
|
-
def publish(
|
|
6
|
-
test:bool=True, # Use Test PyPI if True, real PyPI if False
|
|
7
|
-
):
|
|
8
|
-
"Build and publish package to PyPI. Looks for ~/.pypirc for credentials, otherwise prompts."
|
|
9
|
-
|
|
10
|
-
print("Rebuilding package from notebooks...")
|
|
11
|
-
build()
|
|
12
|
-
|
|
13
|
-
shutil.rmtree('dist', ignore_errors=True)
|
|
14
|
-
print("Building distribution...")
|
|
15
|
-
subprocess.run(['uv', 'build'], check=True)
|
|
16
|
-
|
|
17
|
-
pypirc, cmd = Path.home() / '.pypirc', ['uv', 'publish']
|
|
18
|
-
section = 'testpypi' if test else 'pypi'
|
|
19
|
-
|
|
20
|
-
if test: cmd.extend(['--publish-url', 'https://test.pypi.org/legacy/'])
|
|
21
|
-
else: cmd.extend(['--publish-url', 'https://upload.pypi.org/legacy/'])
|
|
22
|
-
|
|
23
|
-
if pypirc.exists():
|
|
24
|
-
config = configparser.ConfigParser()
|
|
25
|
-
config.read(pypirc)
|
|
26
|
-
if section in config:
|
|
27
|
-
username, password = config[section].get('username', '__token__'), config[section].get('password', '')
|
|
28
|
-
cmd.extend(['--username', username, '--password', password])
|
|
29
|
-
|
|
30
|
-
print(f"Publishing to {'Test ' if test else ''}PyPI...")
|
|
31
|
-
subprocess.run(cmd, check=True)
|
|
File without changes
|