marimo-dev 0.1.14__tar.gz → 0.2.2__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.
@@ -1,15 +1,21 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: marimo-dev
3
- Version: 0.1.14
3
+ Version: 0.2.2
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
8
  Requires-Dist: fastcore>=1.10.0
9
+ Requires-Dist: ipython>=9.8.0
10
+ Requires-Dist: marimo>=0.19.7
11
+ Requires-Dist: mohtml>=0.1.11
12
+ Requires-Dist: pydantic-ai-slim>=1.50.0
13
+ Requires-Dist: pytest>=9.0.2
9
14
  Requires-Dist: python-fasthtml>=0.12.37
10
15
  Requires-Dist: youtube-transcript-api>=1.2.3
11
16
  Requires-Python: >=3.12
12
17
  Project-URL: Repository, https://github.com/deufel/m-dev
18
+ Project-URL: PyPI, https://pypi.org/project/marimo-dev/
13
19
  Description-Content-Type: text/markdown
14
20
 
15
21
 
@@ -87,7 +93,8 @@ from .core import greet
87
93
 
88
94
  ## What gets exported
89
95
 
90
- Only self-contained functions and classes are exported. A self-contained function must: reference no variables outside its scope (except from the setup cell), and be the only function in its cell. See [reusing functions](https://docs.marimo.io/guides/reusing_functions/) for details. Everything else - test code, UI elements, exploratory cells - stays in your notebooks.
96
+ 1. constants in setup cells
97
+ 2. [self-contained functions and classes](https://docs.marimo.io/guides/reusing_functions/)
91
98
 
92
99
  ## Hash pipe directives
93
100
 
@@ -101,12 +108,12 @@ def helper():
101
108
 
102
109
  @app.function
103
110
  #| internal
104
- def _private():
111
+ def private():
105
112
  pass # not added to __all__
106
113
 
107
114
  @app.function
108
115
  #| nodoc internal
109
- def _helper():
116
+ def helper():
110
117
  pass # neither exported nor documented
111
118
  ```
112
119
 
@@ -142,13 +149,14 @@ skip_prefixes = ["XX_", "test_"] # ignore these files
142
149
  ## Commands
143
150
 
144
151
  ```bash
145
- md build # build package from notebooks
152
+ md build # build package from notebooks and make docs
153
+ md docs # build the static docs (beta)
146
154
  md publish --test # publish to Test PyPI
147
155
  md publish # publish to PyPI
148
156
  md tidy # remove __pycache__ and cache files
149
157
  md nuke # remove all build artifacts (dist, docs, src, temp*)
150
158
  ```
151
- If you make a temp folder it will be explicitly removed when running `md nuke`
159
+ *If you make a temp folder it will be explicitly removed when running `md nuke`*
152
160
 
153
161
  ## Dependencies
154
162
 
@@ -73,7 +73,8 @@ from .core import greet
73
73
 
74
74
  ## What gets exported
75
75
 
76
- Only self-contained functions and classes are exported. A self-contained function must: reference no variables outside its scope (except from the setup cell), and be the only function in its cell. See [reusing functions](https://docs.marimo.io/guides/reusing_functions/) for details. Everything else - test code, UI elements, exploratory cells - stays in your notebooks.
76
+ 1. constants in setup cells
77
+ 2. [self-contained functions and classes](https://docs.marimo.io/guides/reusing_functions/)
77
78
 
78
79
  ## Hash pipe directives
79
80
 
@@ -87,12 +88,12 @@ def helper():
87
88
 
88
89
  @app.function
89
90
  #| internal
90
- def _private():
91
+ def private():
91
92
  pass # not added to __all__
92
93
 
93
94
  @app.function
94
95
  #| nodoc internal
95
- def _helper():
96
+ def helper():
96
97
  pass # neither exported nor documented
97
98
  ```
98
99
 
@@ -128,13 +129,14 @@ skip_prefixes = ["XX_", "test_"] # ignore these files
128
129
  ## Commands
129
130
 
130
131
  ```bash
131
- md build # build package from notebooks
132
+ md build # build package from notebooks and make docs
133
+ md docs # build the static docs (beta)
132
134
  md publish --test # publish to Test PyPI
133
135
  md publish # publish to PyPI
134
136
  md tidy # remove __pycache__ and cache files
135
137
  md nuke # remove all build artifacts (dist, docs, src, temp*)
136
138
  ```
137
- If you make a temp folder it will be explicitly removed when running `md nuke`
139
+ *If you make a temp folder it will be explicitly removed when running `md nuke`*
138
140
 
139
141
  ## Dependencies
140
142
 
@@ -4,12 +4,17 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "marimo-dev"
7
- version = "0.1.14"
7
+ version = "0.2.2"
8
8
  description = "Build and publish python packages from marimo notebooks"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
11
11
  dependencies = [
12
12
  "fastcore>=1.10.0",
13
+ "ipython>=9.8.0",
14
+ "marimo>=0.19.7",
15
+ "mohtml>=0.1.11",
16
+ "pydantic-ai-slim>=1.50.0",
17
+ "pytest>=9.0.2",
13
18
  "python-fasthtml>=0.12.37",
14
19
  "youtube-transcript-api>=1.2.3",
15
20
  ]
@@ -34,6 +39,8 @@ classifiers = [
34
39
 
35
40
  [project.urls]
36
41
  Repository = "https://github.com/deufel/m-dev"
42
+ PyPI = "https://pypi.org/project/marimo-dev/"
43
+
37
44
 
38
45
  # Marimo-Dev settings
39
46
  [tool.marimo-dev]
@@ -44,7 +51,7 @@ Repository = "https://github.com/deufel/m-dev"
44
51
  skip_prefixes = ["XX_", "test_"]
45
52
 
46
53
  # Remove this when ready to upload this is a saftey net for not accidentally uploading
47
- # classifiers = ["Private :: Do Not Upload"]
54
+ classifiers = ["Private :: Do Not Upload"]
48
55
 
49
56
  # Development packages
50
57
  [dependency-groups]
@@ -1,26 +1,33 @@
1
1
  """Build and publish python packages from marimo notebooks"""
2
- __version__ = '0.1.14'
2
+ __version__ = '0.2.2'
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 cls_sig, fn_sig, sig, write_llms
8
- from .build import build, tidy, nuke
7
+ from .docs import cls_sig, fn_sig, sig, write_llms, exp_type, render_param, nb_path, render_node, render_module_page, build_docs, export_wasm, write_nojekyll, html_preview, render_index_page, Icon
8
+ from .build import build, tidy, nuke, get_pypi_name, extract_import_names, pep723_header, bundle
9
9
  from .publish import publish
10
- from .cli import main
11
10
  __all__ = [
12
11
  "Config",
12
+ "Icon",
13
13
  "Kind",
14
14
  "Node",
15
15
  "Param",
16
16
  "build",
17
+ "build_docs",
18
+ "bundle",
17
19
  "clean",
18
20
  "cls_sig",
21
+ "exp_type",
22
+ "export_wasm",
23
+ "extract_import_names",
19
24
  "fn_sig",
25
+ "get_pypi_name",
26
+ "html_preview",
20
27
  "inline_doc",
21
28
  "is_export",
22
- "main",
23
29
  "nb_name",
30
+ "nb_path",
24
31
  "nuke",
25
32
  "parse_class_methods",
26
33
  "parse_class_params",
@@ -32,9 +39,14 @@ __all__ = [
32
39
  "parse_node",
33
40
  "parse_params",
34
41
  "parse_ret",
42
+ "pep723_header",
35
43
  "publish",
36
44
  "read_config",
37
45
  "read_meta",
46
+ "render_index_page",
47
+ "render_module_page",
48
+ "render_node",
49
+ "render_param",
38
50
  "rewrite_imports",
39
51
  "scan",
40
52
  "sig",
@@ -44,4 +56,5 @@ __all__ = [
44
56
  "write_init",
45
57
  "write_llms",
46
58
  "write_mod",
59
+ "write_nojekyll",
47
60
  ]
@@ -0,0 +1,98 @@
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
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
+ # Collect all nodes
74
+ all_nodes = [n for _, nodes in mods for n in nodes]
75
+
76
+ # Extract dependencies
77
+ import_names = extract_import_names(all_nodes)
78
+ # Filter out stdlib and local modules
79
+ mod_names = [m for m, _ in mods]
80
+ external = {get_pypi_name(n) for n in import_names if n not in mod_names}
81
+
82
+ # Build output
83
+ header = pep723_header(external)
84
+ imports = '\n'.join(n.src for n in all_nodes if n.kind == Kind.IMP)
85
+ consts = '\n'.join(n.src for n in all_nodes if n.kind == Kind.CONST)
86
+ exports = '\n\n'.join(clean(n.src) for n in all_nodes if n.kind == Kind.EXP)
87
+
88
+ content = '\n\n'.join(p for p in [header, imports, consts, exports] if p.strip())
89
+
90
+ # Determine output path
91
+ if name:
92
+ out_path = Path(root) / name
93
+ else:
94
+ out_path = Path(root) / cfg.out / meta['name'] / '__init__.py'
95
+
96
+ out_path.parent.mkdir(parents=True, exist_ok=True)
97
+ out_path.write_text(content)
98
+ return f"Bundled to {out_path}"
@@ -44,3 +44,4 @@ class Node:
44
44
  ret: tuple[str,str]|None = None
45
45
  hash_pipes: list[str] = field(default_factory=list)
46
46
  module: str = ''
47
+ lineno: int = 0
@@ -0,0 +1,238 @@
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=Found%20in%20{nb}%23L{n.lineno}", target="_blank", style=link_style) if repo_url and nb else None
112
+ header = Div(
113
+ Div(tag, full_name, style="display: flex; align-items: center;"),
114
+ Div(copy_btn, source_btn, edit_btn, blame_btn, history_btn, issue_btn, style="display: flex; align-items: center; gap: 0.5rem;"),
115
+ style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem;")
116
+ 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
117
+ code_block = Div(
118
+ 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;"),
119
+ 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;"),
120
+ style="display: flex; background: #1a1a1a; border-top: 1px solid #2a2a2a;")
121
+ return Article(header, doc_line, code_block, style="margin-bottom: 0.75rem; border-radius: 8px; overflow: hidden; background: #1e1e1e;")
122
+
123
+ def render_module_page(
124
+ mod_name,
125
+ mod_nodes,
126
+ all_mod_names,
127
+ meta,
128
+ root='.'):
129
+ '''Builds a Module Page'''
130
+ repo_url = meta.get('urls', {}).get('Repository')
131
+ exp_nodes = [n for n in mod_nodes if n.kind == Kind.EXP]
132
+ content = Div(*[render_node(n, repo_url, root) for n in exp_nodes], style="padding: 1rem; background: #121212; overflow-y: auto;")
133
+ head_elements = [
134
+ Script(type="module", src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js"),
135
+ Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css"),
136
+ Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"),
137
+ Script("hljs.highlightAll();"),
138
+ Style("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; } code { font-family: 'SF Mono', Consolas, monospace; }"),
139
+ Title(f"{mod_name} - {meta['name']}")]
140
+ 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"})
141
+ 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]
142
+ nav = Nav(
143
+ H3(meta['name'], style="margin: 0 0 1rem 0; color: #fff;"),
144
+ search_input,
145
+ Ul(*nav_links, style="list-style: none; padding: 0; margin: 0;"),
146
+ style="padding: 1rem; background: #1a1a1a; min-width: 180px;")
147
+ 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;"
148
+ 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;")
149
+ header = Header(
150
+ 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;"),
151
+ style="padding: 1rem; background: #1e1e1e; border-bottom: 1px solid #333;")
152
+ 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: ''}"})
153
+ return Html(Head(*head_elements), body)
154
+
155
+ def build_docs(
156
+ root='.' # the project root (this should never really change)
157
+ ):
158
+ '''Builds the static documentation website'''
159
+ cfg = read_config(root)
160
+ meta = read_meta(root)
161
+ _, mods = scan(root)
162
+ mod_names = [name for name, _ in mods]
163
+ docs_path = Path(root) / cfg.docs
164
+ docs_path.mkdir(exist_ok=True)
165
+ (docs_path / "index.html").write_text(to_xml(render_index_page(meta, mods)))
166
+ for mod_name, mod_nodes in mods:
167
+ (docs_path / f"{mod_name}.html").write_text(to_xml(render_module_page(mod_name, mod_nodes, mod_names, meta, root)))
168
+ return f"Generated index + {len(mods)} module pages in {docs_path}"
169
+
170
+ cfg = read_config(root)
171
+
172
+ nbs_dir = Path(root) / cfg.nbs
173
+
174
+ wasm_dir = Path(root) / cfg.docs / 'wasm'
175
+
176
+ wasm_dir.mkdir(parents=True, exist_ok=True)
177
+
178
+ for f in nbs_dir.glob('*.py'):
179
+ name = nb_name(f, root)
180
+ if name:
181
+ os.system(f'marimo export html-wasm {f} -o {wasm_dir}/{name} --mode edit')
182
+
183
+ def write_nojekyll(root='.'):
184
+ cfg = read_config(root)
185
+ Path(root, cfg.docs, '.nojekyll').touch()
186
+
187
+ def html_preview(width='100%', height='300px'):
188
+ "Display FT components in an IFrame"
189
+ def _preview(*components): show(Iframe(srcdoc=to_xml(components[0] if len(components) == 1 else Div(*components)), width=width, height=height))
190
+ return _preview
191
+
192
+ def render_index_page(meta, mods, repo_url=None):
193
+ mod_names = [name for name, _ in mods]
194
+ head_elements = [
195
+ Script(type="module", src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js"),
196
+ Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css"),
197
+ Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"),
198
+ Script("hljs.highlightAll();"),
199
+ Style("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; } code { font-family: 'SF Mono', Consolas, monospace; }"),
200
+ Title(meta['name'])]
201
+ nav_links = [Li(A(m, href=f"{m}.html", style="color: #aaa; text-decoration: none;")) for m in mod_names]
202
+ nav = Nav(
203
+ H3(meta['name'], style="margin: 0 0 1rem 0; color: #fff;"),
204
+ Ul(*nav_links, style="list-style: none; padding: 0; margin: 0;"),
205
+ style="padding: 1rem; background: #1a1a1a; min-width: 180px;")
206
+ 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;"),
207
+ style="padding: 1rem; background: #1e1e1e; border-radius: 8px;"), href=f"{name}.html", style="text-decoration: none;") for name, nodes in mods]
208
+ content = Div(
209
+ H1(meta['name'], style="margin: 0 0 0.5rem 0; color: #fff;"),
210
+ P(meta['desc'], style="color: #888; margin: 0 0 2rem 0;"),
211
+ P(f"Version {meta['version']}", style="color: #666; margin: 0 0 2rem 0; font-size: 0.9rem;"),
212
+ H2("Modules", style="color: #fff; margin: 0 0 1rem 0; font-size: 1.2rem;"),
213
+ Div(*module_cards, style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem;"),
214
+ style="padding: 2rem; flex: 1;")
215
+ body = Body(nav, content, style="display: flex; min-height: 100vh; margin: 0; background: #121212;")
216
+ return Html(Head(*head_elements), body)
217
+
218
+ def Icon(name: str, # name of the icon MUST be in icon_dict
219
+ size=24, # value to be passed to height and width of the icon
220
+ stroke=1.5, # stroke width
221
+ cls=None, # css class
222
+ icon_dict:dict=icons, # Dict of icons {"name":"<svg...>"}
223
+ **kwargs # passed to through to FT
224
+ ) -> 'Any': # Follow recomendation from fastHTML docs
225
+ '''
226
+ Creates a custom html compliant <icon-{name}>...
227
+ Intended to be used with a Global Dict of icons {"home": "<svg...", "info": "<svg..."}
228
+ Icon('home') -> <icon-home> .... </icon-home>
229
+ '''
230
+ if name not in icon_dict: raise ValueError(f"Icon '{name}' not found")
231
+
232
+ # count=1 Replace only the first occurrence of width & height 99% of time this is what you want
233
+ svg_string = icon_dict[name]
234
+ svg_string = re.sub(r'width="\d+"', f'width="{size}"', svg_string, count=1)
235
+ svg_string = re.sub(r'height="\d+"', f'height="{size}"', svg_string, count=1)
236
+ svg_string = re.sub(r'stroke-width="\d+"', f'stroke-width="{stroke}"', svg_string)
237
+
238
+ return ft(f'icon-{name}', NotStr(svg_string), cls=cls, **kwargs)
@@ -92,15 +92,15 @@ def parse_const(
92
92
  n:ast.AST, # AST node to check
93
93
  ls:list, # source lines (unused)
94
94
  )->Node|None: # Node if constant assignment, else None
95
- "Extract constant definition (dunder-prefixed, non-dunder-suffixed)."
95
+ "Extract constant definition from assignment."
96
96
  if not isinstance(n, ast.Assign): return None
97
97
  for t in n.targets:
98
- if isinstance(t, ast.Name) and t.id.startswith('__') and not t.id.endswith('__'): return Node(Kind.CONST, t.id, ast.unparse(n))
98
+ if isinstance(t, ast.Name): return Node(Kind.CONST, t.id, ast.unparse(n))
99
99
 
100
100
  def parse_export(
101
101
  n:ast.AST, # AST node to check
102
102
  ls:list, # source lines for inline doc and decorators
103
- cfg:Config, # configuration object
103
+ cfg:Config # configuration object
104
104
  )->Node|None: # Node if exported function/class, else None
105
105
  "Extract exported function or class decorated with export decorators from config."
106
106
  if not isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): return None
@@ -108,8 +108,8 @@ def parse_export(
108
108
  if not export_dec or n.name.startswith('test_'): return None
109
109
  doc,src = ast.get_docstring(n) or '', src_with_decs(n, ls)
110
110
  hash_pipes = parse_hash_pipe(ls, export_dec)
111
- if isinstance(n, ast.ClassDef): return Node(Kind.EXP, n.name, src, doc, parse_class_params(n, ls), parse_class_methods(n, ls), None, hash_pipes)
112
- return Node(Kind.EXP, n.name, src, doc, parse_params(n, ls), [], parse_ret(n, ls), hash_pipes)
111
+ if isinstance(n, ast.ClassDef): return Node(Kind.EXP, n.name, src, doc, parse_class_params(n, ls), parse_class_methods(n, ls), None, hash_pipes, '', n.lineno)
112
+ return Node(Kind.EXP, n.name, src, doc, parse_params(n, ls), [], parse_ret(n, ls), hash_pipes, '', n.lineno)
113
113
 
114
114
  def parse_node(
115
115
  n:ast.AST, # AST node to parse
@@ -118,10 +118,22 @@ def parse_node(
118
118
  ): # yields Node objects for imports, constants, and exports
119
119
  "Extract importable nodes from an AST node."
120
120
  ls = src.splitlines()
121
+
122
+ # Handle setup cells
121
123
  if isinstance(n, ast.With):
122
124
  for s in n.body:
123
125
  if (node := parse_import(s, ls)): yield node
124
126
  if (node := parse_const(s, ls)): yield node
127
+
128
+ # Handle export-named cells (e.g. def export(): or def export_main():)
129
+ if isinstance(n, ast.FunctionDef) and n.name.startswith('export'):
130
+ body = [s for s in n.body if not isinstance(s, ast.Return)]
131
+ if body:
132
+ src = '\n\n'.join(ast.unparse(s) for s in body)
133
+ yield Node(Kind.EXP, n.name, src)
134
+ return
135
+
136
+ # Handle decorated exports
125
137
  if (node := parse_export(n, ls, cfg)): yield node
126
138
 
127
139
  def parse_file(
@@ -138,13 +150,27 @@ def parse_file(
138
150
 
139
151
  def read_meta(
140
152
  root='.', # project root directory containing pyproject.toml
141
- )->dict: # metadata dict with name, version, desc, license, author
153
+ )->dict: # metadata dict with name, version, desc, license, author, urls
142
154
  "Read project metadata from pyproject.toml."
143
- with open(Path(root)/'pyproject.toml', 'rb') as f: p = tomllib.load(f).get('project', {})
155
+ with open(Path(root)/'pyproject.toml', 'rb') as f:
156
+ p = tomllib.load(f).get('project', {})
157
+
158
+ # Extract author
144
159
  a = (p.get('authors') or [{}])[0]
145
160
  author = f"{a.get('name','')} <{a.get('email','')}>".strip(' <>') if isinstance(a, dict) else str(a)
161
+
162
+ # Extract license
146
163
  lic = p.get('license', {})
147
- return dict(name=p.get('name',''), version=p.get('version','0.0.0'), desc=p.get('description',''), license=lic.get('text','') if isinstance(lic, dict) else lic, author=author)
164
+ license_text = lic.get('text','') if isinstance(lic, dict) else lic
165
+
166
+ return dict(
167
+ name=p.get('name',''),
168
+ version=p.get('version','0.0.0'),
169
+ desc=p.get('description',''),
170
+ license=license_text,
171
+ author=author,
172
+ urls=p.get('urls', {})
173
+ )
148
174
 
149
175
  def nb_name(
150
176
  f: Path, # file path to extract notebook name from
@@ -1,40 +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
4
- from .docs import write_llms
5
- from pathlib import Path
6
- import ast, shutil, re
7
-
8
- def build(
9
- root='.', # root directory containing pyproject.toml
10
- )->str: # path to built package
11
- "Build a Python package from notebooks."
12
- cfg = read_config(root)
13
- meta, mods = scan(root)
14
- mod_names = [name for name, _ in mods]
15
- pkg = Path(root) / cfg.out / meta['name'].replace('-', '_')
16
- if pkg.exists(): shutil.rmtree(pkg)
17
- pkg.mkdir(parents=True, exist_ok=True)
18
- for name, nodes in mods:
19
- stripped = re.sub(r'^[a-z]_', '', name)
20
- if stripped != 'index' and any(n.kind == Kind.EXP for n in nodes): write_mod(pkg/f'{stripped}.py', nodes, mod_names)
21
- write_init(pkg/'__init__.py', meta, mods)
22
- all_exp = [n for _, nodes in mods for n in nodes if n.kind == Kind.EXP]
23
- if all_exp: write_llms(meta, all_exp)
24
- return str(pkg)
25
-
26
- def tidy():
27
- "Remove cache and temporary files (__pycache__, __marimo__, .pytest_cache, etc)."
28
- import shutil
29
- for p in Path('.').rglob('__pycache__'): shutil.rmtree(p, ignore_errors=True)
30
- for p in Path('.').rglob('__marimo__'): shutil.rmtree(p, ignore_errors=True)
31
- for p in Path('.').rglob('.pytest_cache'): shutil.rmtree(p, ignore_errors=True)
32
- for p in Path('.').rglob('*.pyc'): p.unlink(missing_ok=True)
33
- print("Cleaned cache files")
34
-
35
- def nuke():
36
- "Remove all build artifacts (dist, docs, src) and cache files."
37
- import shutil
38
- tidy()
39
- for d in ['dist', 'docs', 'src', 'temp']: shutil.rmtree(d, ignore_errors=True)
40
- print("Nuked build artifacts")
@@ -1,18 +0,0 @@
1
- from .build import build, tidy, nuke
2
- from .publish import publish
3
- import sys, subprocess
4
- from pathlib import Path
5
-
6
- def main():
7
- if len(sys.argv) < 2: print("Usage: md [build|publish|tidy|nuke]"); sys.exit(1)
8
- cmd = sys.argv[1]
9
- if cmd == 'build':
10
- print(f"Built package at: {build()}")
11
- elif cmd == 'publish':
12
- test = '--test' in sys.argv or '-t' in sys.argv
13
- target = "TestPyPI" if test else "PyPI"
14
- if input(f"Publish to {target}? [y/N] ").lower() != 'y': print("Aborted"); sys.exit(0)
15
- publish(test=test)
16
- elif cmd == 'tidy': tidy()
17
- elif cmd == 'nuke': nuke()
18
- else: print(f"Unknown command: {cmd}"); sys.exit(1)
@@ -1,58 +0,0 @@
1
- from .core import Kind, Param, Node, Config, read_config
2
- from pathlib import Path
3
- import ast
4
- import marimo as mo
5
- from functools import partial
6
-
7
- def cls_sig(
8
- n:Node, # the node to generate signature for
9
- dataclass=False, # whether to include @dataclass decorator
10
- )->str: # formatted class signature
11
- "Generate a class signature string."
12
- header = f"@dataclass\nclass {n.name}:" if dataclass else f"class {n.name}:"
13
- lines = [header]
14
- if n.doc: lines.append(f' """{n.doc}"""')
15
- for p in n.params:
16
- attr = f" {p.name}{f': {p.anno}' if p.anno else ''}{f' = {p.default}' if p.default else ''}"
17
- if p.doc: attr += f" # {p.doc}"
18
- lines.append(attr)
19
- for m in n.methods:
20
- ps = ', '.join(f"{p.name}{f': {p.anno}' if p.anno else ''}{f'={p.default}' if p.default else ''}" for p in m['params'])
21
- ret = f" -> {m['ret'][0]}" if m['ret'] else ""
22
- lines.append(f" def {m['name']}({ps}){ret}:")
23
- if m['doc']: lines.append(f' """{m["doc"]}"""')
24
- return '\n'.join(lines)
25
-
26
- def fn_sig(n, is_async=False):
27
- "Generate a function signature string with inline parameter documentation."
28
- prefix = 'async def' if is_async else 'def'
29
- ret = f" -> {n.ret[0]}" if n.ret else ""
30
- if not n.params:
31
- sig = f"{prefix} {n.name}(){ret}:"
32
- return f'{sig}\n """{n.doc}"""' if n.doc else sig
33
- 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]
34
- params[-1] = params[-1].replace(',', '')
35
- lines = [f"{prefix} {n.name}("] + params + [f"){ret}:"]
36
- if n.doc: lines.append(f' """{n.doc}"""')
37
- return '\n'.join(lines)
38
-
39
- def sig(
40
- n:Node, # the node to generate signature for
41
- )->str: # formatted signature string
42
- "Generate a signature string for a class or function node."
43
- src = n.src.lstrip()
44
- if n.methods or src.startswith('class ') or src.startswith('@dataclass'):
45
- return cls_sig(n, dataclass=src.startswith('@dataclass'))
46
- return fn_sig(n, is_async=src.startswith('async def'))
47
-
48
- def write_llms(
49
- meta: dict, # project metadata from pyproject.toml
50
- nodes: list, # list of Node objects to document
51
- root: str='.' # root directory containing pyproject.toml
52
- ):
53
- "Write API signatures to llms.txt file for LLM consumption."
54
- cfg = read_config(root)
55
- sigs = '\n\n'.join(sig(n) for n in nodes if not n.name.startswith('__') and 'nodoc' not in n.hash_pipes)
56
- content = f"# {meta['name']}\n\n> {meta['desc']}\n\nVersion: {meta['version']}\n\n## API\n\n```python\n{sigs}\n```"
57
- Path(cfg.docs).mkdir(exist_ok=True)
58
- (Path(cfg.docs)/'llms.txt').write_text(content)