afterpython 0.4.1__tar.gz → 0.4.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.
Files changed (72) hide show
  1. {afterpython-0.4.1 → afterpython-0.4.2}/PKG-INFO +3 -2
  2. {afterpython-0.4.1 → afterpython-0.4.2}/README.md +2 -1
  3. {afterpython-0.4.1 → afterpython-0.4.2}/pyproject.toml +1 -1
  4. {afterpython-0.4.1 → afterpython-0.4.2}/pyproject.toml.orig +1 -1
  5. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/__init__.py +2 -0
  6. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/jupyter_notebook.py +23 -7
  7. afterpython-0.4.2/src/afterpython/builders/marimo_content.py +467 -0
  8. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/marimo_notebook.py +7 -6
  9. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/build.py +72 -55
  10. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/clean.py +6 -0
  11. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/dev.py +19 -1
  12. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/main.py +3 -1
  13. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/__init__.py +0 -0
  14. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/_io/toml.py +0 -0
  15. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/_io/yaml.py +0 -0
  16. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/_paths.py +0 -0
  17. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/_typing.py +0 -0
  18. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/build_pdoc.py +0 -0
  19. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/comments_json.py +0 -0
  20. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/content_json.py +0 -0
  21. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/faq_json.py +0 -0
  22. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/index_md.py +0 -0
  23. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/llms_txt.py +0 -0
  24. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/markdown.py +0 -0
  25. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/metadata.py +0 -0
  26. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/builders/url_md.py +0 -0
  27. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/__init__.py +0 -0
  28. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/add.py +0 -0
  29. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/bump.py +0 -0
  30. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/changelog.py +0 -0
  31. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/check.py +0 -0
  32. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/commit_msg.py +0 -0
  33. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/format.py +0 -0
  34. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/init.py +0 -0
  35. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/init_branch_rules.py +0 -0
  36. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/install.py +0 -0
  37. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/lint.py +0 -0
  38. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/lock.py +0 -0
  39. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/prek.py +0 -0
  40. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/preview.py +0 -0
  41. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/release.py +0 -0
  42. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/remove.py +0 -0
  43. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/serve.py +0 -0
  44. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/start.py +0 -0
  45. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/sync.py +0 -0
  46. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/cli/commands/update.py +0 -0
  47. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/const.py +0 -0
  48. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/main.py +0 -0
  49. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/pcu.py +0 -0
  50. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/py.typed +0 -0
  51. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/afterpython-template.toml +0 -0
  52. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/ci-workflow-template.yml +0 -0
  53. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/cliff-template.toml +0 -0
  54. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/dependabot-template.yml +0 -0
  55. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/deploy-workflow-template.yml +0 -0
  56. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/pdoc/custom.css +0 -0
  57. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/pdoc/frame.html.jinja2 +0 -0
  58. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/pdoc/syntax-highlighting.css +0 -0
  59. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/pdoc/theme.css +0 -0
  60. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/pre-commit-config-template.yaml +0 -0
  61. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/release-workflow-template.yml +0 -0
  62. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/ruff-template.toml +0 -0
  63. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/templates/ty-template.toml +0 -0
  64. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/tools/_afterpython.py +0 -0
  65. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/tools/_git.py +0 -0
  66. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/tools/branch_rules.py +0 -0
  67. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/tools/github_actions.py +0 -0
  68. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/tools/myst.py +0 -0
  69. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/tools/prek.py +0 -0
  70. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/tools/pyproject.py +0 -0
  71. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/utils.py +0 -0
  72. {afterpython-0.4.1 → afterpython-0.4.2}/src/afterpython/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: afterpython
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: CLI tool to streamline Python package maintenance and generate a dedicated project website
5
5
  Keywords: package-maintenance,project-website,static-site-generator,developer-tools,documentation,cli-tool,ci/cd,mystmd,semantic-release,python-packaging
6
6
  Author: Stephen Yau
@@ -54,6 +54,7 @@ Description-Content-Type: text/markdown
54
54
  [MyST]: https://mystmd.org
55
55
  [MyST Markdown]: https://mystmd.org/spec/
56
56
  [Jupyter Notebook]: https://jupyter.org
57
+ [marimo]: https://marimo.io
57
58
  [pre-commit]: https://pre-commit.com
58
59
  [pagefind]: https://pagefind.app
59
60
  [SemVer]: https://semver.org
@@ -81,7 +82,7 @@ First-time maintainers must learn multiple tools and concepts, e.g. [MyST], [Sem
81
82
 
82
83
 
83
84
  ## Core Features
84
- - [x] Write content directly in [MyST Markdown] or [Jupyter Notebook]
85
+ - [x] Write content directly in [MyST Markdown], [Jupyter Notebook] or [marimo] notebooks, with reproducible outputs for marimo
85
86
  - [x] Go from writing to **website deployment in minutes** — no need to learn any of the underlying tools
86
87
  - [x] Centralize all your content in a modern, **unified project website** — from documentation to blog posts
87
88
  - [x] Zero-config orchestration — Pre-configured modern tooling with sane defaults (see [Tech Stack](#tech-stack)), so you can start maintaining packages immediately **without learning each tool**
@@ -14,6 +14,7 @@
14
14
  [MyST]: https://mystmd.org
15
15
  [MyST Markdown]: https://mystmd.org/spec/
16
16
  [Jupyter Notebook]: https://jupyter.org
17
+ [marimo]: https://marimo.io
17
18
  [pre-commit]: https://pre-commit.com
18
19
  [pagefind]: https://pagefind.app
19
20
  [SemVer]: https://semver.org
@@ -41,7 +42,7 @@ First-time maintainers must learn multiple tools and concepts, e.g. [MyST], [Sem
41
42
 
42
43
 
43
44
  ## Core Features
44
- - [x] Write content directly in [MyST Markdown] or [Jupyter Notebook]
45
+ - [x] Write content directly in [MyST Markdown], [Jupyter Notebook] or [marimo] notebooks, with reproducible outputs for marimo
45
46
  - [x] Go from writing to **website deployment in minutes** — no need to learn any of the underlying tools
46
47
  - [x] Centralize all your content in a modern, **unified project website** — from documentation to blog posts
47
48
  - [x] Zero-config orchestration — Pre-configured modern tooling with sane defaults (see [Tech Stack](#tech-stack)), so you can start maintaining packages immediately **without learning each tool**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "afterpython"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "CLI tool to streamline Python package maintenance and generate a dedicated project website"
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "afterpython"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "CLI tool to streamline Python package maintenance and generate a dedicated project website"
5
5
  license = "Apache-2.0"
6
6
  authors = [
@@ -8,6 +8,7 @@ from afterpython.builders.index_md import (
8
8
  )
9
9
  from afterpython.builders.jupyter_notebook import build_jupyter_notebooks
10
10
  from afterpython.builders.llms_txt import build_llms_txt
11
+ from afterpython.builders.marimo_content import build_marimo_content
11
12
  from afterpython.builders.marimo_notebook import build_marimo_readme
12
13
  from afterpython.builders.markdown import build_markdown
13
14
  from afterpython.builders.metadata import build_metadata
@@ -19,6 +20,7 @@ __all__ = (
19
20
  "build_faq_json",
20
21
  "build_jupyter_notebooks",
21
22
  "build_llms_txt",
23
+ "build_marimo_content",
22
24
  "build_marimo_readme",
23
25
  "build_markdown",
24
26
  "build_metadata",
@@ -17,7 +17,7 @@ def _read_notebook(notebook_path: Path, content_path: Path) -> dict | None:
17
17
  Notebook dict if valid, None if invalid or error occurred
18
18
  """
19
19
  try:
20
- with open(notebook_path) as f:
20
+ with open(notebook_path, encoding="utf-8") as f:
21
21
  notebook = json.load(f)
22
22
  except json.JSONDecodeError:
23
23
  click.echo(f"✗ Skipping {content_path} - invalid or empty JSON")
@@ -36,8 +36,11 @@ def _read_notebook(notebook_path: Path, content_path: Path) -> dict | None:
36
36
 
37
37
  def _write_notebook(notebook: dict, notebook_path: Path):
38
38
  """Write a notebook dict to file."""
39
- with open(notebook_path, "w") as f:
40
- json.dump(notebook, f, indent=1)
39
+ # match Jupyter's own formatting (unescaped unicode, trailing newline) so
40
+ # rewriting a committed notebook doesn't produce a noisy diff
41
+ with open(notebook_path, "w", encoding="utf-8") as f:
42
+ json.dump(notebook, f, indent=1, ensure_ascii=False)
43
+ f.write("\n")
41
44
 
42
45
 
43
46
  def _has_frontmatter(notebook: dict) -> bool:
@@ -243,8 +246,10 @@ def build_jupyter_notebooks():
243
246
 
244
247
  # Iterate over all .ipynb files once
245
248
  for notebook_path in path.rglob("*.ipynb"):
246
- # Skip files in _build directory
247
- if "_build" in notebook_path.parts:
249
+ # Skip MyST output and hidden tool dirs (.ipynb_checkpoints/,
250
+ # .virtual_documents/), which aren't content
251
+ rel_parts = notebook_path.relative_to(path).parts
252
+ if "_build" in rel_parts or any(p.startswith(".") for p in rel_parts):
248
253
  continue
249
254
 
250
255
  # Get path relative to afterpython/
@@ -264,9 +269,20 @@ def build_jupyter_notebooks():
264
269
  # Track if notebook was modified
265
270
  modified = False
266
271
 
267
- # Add molab badge if enabled
272
+ # Add molab badge if enabled. A notebook generated from a marimo .py
273
+ # isn't committed, so the badge must open the .py instead.
268
274
  if add_molab_badge and github_url:
269
- _add_molab_badge_to_notebook(notebook, github_url, content_path)
275
+ generated_from = (
276
+ notebook.get("metadata", {})
277
+ .get("afterpython", {})
278
+ .get("generated_from")
279
+ )
280
+ molab_path = (
281
+ content_path.with_name(generated_from)
282
+ if generated_from
283
+ else content_path
284
+ )
285
+ _add_molab_badge_to_notebook(notebook, github_url, molab_path)
270
286
  modified = True
271
287
 
272
288
  # Add thumbnail to frontmatter
@@ -0,0 +1,467 @@
1
+ """Convert marimo notebooks in content folders into Jupyter notebooks for MyST.
2
+
3
+ MyST can't read marimo `.py` notebooks, so before MyST runs we export each one
4
+ to an `.ipynb` next to it. `marimo export ipynb --sandbox --include-outputs`
5
+ runs the notebook in its own uv environment, built from the notebook's PEP 723
6
+ header, so the outputs are reproducible no matter where `ap build` runs.
7
+
8
+ Rules
9
+ -----
10
+ - A content folder is either all-marimo or all-Jupyter (`.md` doesn't count).
11
+ Mixing raises. Generated `.ipynb` files (stamped with
12
+ `metadata.afterpython.generated_from`) don't count as Jupyter.
13
+ - In a marimo folder, `*.ipynb` is added to that folder's `.gitignore`: the
14
+ `.py` is the only source, the `.ipynb` is a build artifact.
15
+ - Generated notebooks get `execute: {skip: true}` frontmatter so
16
+ `myst build --execute` never re-runs them on the shared kernel.
17
+
18
+ Flow
19
+ ----
20
+ build_marimo_content(use_cache) # called by prebuild()
21
+ └─ for each content folder:
22
+ ├─ _scan_folder() → marimo .py / hand-written .ipynb, raises on mixing
23
+ ├─ _remove_orphans() → generated .ipynb whose .py is gone
24
+ ├─ _sync_gitignore() → add/remove the `*.ipynb` line
25
+ └─ convert_marimo_notebook() → export + _postprocess() per notebook
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ import tempfile
37
+ import threading
38
+ import time
39
+ from dataclasses import dataclass, field
40
+ from pathlib import Path
41
+
42
+ import click
43
+ from ruamel.yaml import YAML
44
+ from ruamel.yaml.error import YAMLError
45
+
46
+ import afterpython as ap
47
+ from afterpython.builders.jupyter_notebook import _add_fields_to_frontmatter
48
+ from afterpython.builders.marimo_notebook import is_marimo_notebook
49
+ from afterpython.const import CONTENT_TYPES
50
+
51
+ GITIGNORE_MARKER = (
52
+ "# afterpython: .ipynb files here are generated from marimo notebooks (.py)"
53
+ )
54
+ # *.ipynb: generated notebooks; __marimo__/: marimo's session cache, which
55
+ # `marimo export` writes next to each notebook
56
+ GITIGNORE_PATTERNS = ("*.ipynb", "__marimo__/")
57
+ # re-export taking longer than this in `ap dev` prints a "too slow" hint
58
+ SLOW_EXPORT_SECONDS = 10
59
+ # nbformat cell ids: 1-64 chars of [a-zA-Z0-9-_]
60
+ _MAX_CELL_ID_LENGTH = 64
61
+
62
+
63
+ @dataclass
64
+ class _FolderScan:
65
+ marimo: list[Path] = field(default_factory=list)
66
+ jupyter: list[Path] = field(default_factory=list)
67
+ generated: list[Path] = field(default_factory=list)
68
+
69
+
70
+ def _iter_files(content_path: Path):
71
+ """Content files under a folder, skipping MyST output and hidden tool dirs
72
+ (`.ipynb_checkpoints/`, `.virtual_documents/`), same as url_md."""
73
+ for path in sorted(content_path.rglob("*")):
74
+ rel_parts = path.relative_to(content_path).parts
75
+ if "_build" in rel_parts or any(p.startswith(".") for p in rel_parts):
76
+ continue
77
+ if path.is_file():
78
+ yield path
79
+
80
+
81
+ def _generated_from(notebook_path: Path) -> str | None:
82
+ """The `.py` filename a generated notebook was exported from, else None."""
83
+ try:
84
+ notebook = json.loads(notebook_path.read_text(encoding="utf-8"))
85
+ except (OSError, ValueError):
86
+ return None
87
+ if not isinstance(notebook, dict):
88
+ return None
89
+ stamp = notebook.get("metadata", {}).get("afterpython", {})
90
+ return stamp.get("generated_from") if isinstance(stamp, dict) else None
91
+
92
+
93
+ def _scan_folder(content_path: Path) -> _FolderScan:
94
+ scan = _FolderScan()
95
+ for path in _iter_files(content_path):
96
+ if path.suffix == ".py" and is_marimo_notebook(path):
97
+ scan.marimo.append(path)
98
+ elif path.suffix == ".ipynb":
99
+ if _generated_from(path):
100
+ scan.generated.append(path)
101
+ else:
102
+ scan.jupyter.append(path)
103
+ return scan
104
+
105
+
106
+ def _check_not_mixed(content_path: Path, scan: _FolderScan):
107
+ if not (scan.marimo and scan.jupyter):
108
+ return
109
+
110
+ def _names(paths: list[Path]) -> str:
111
+ return ", ".join(p.relative_to(content_path).as_posix() for p in paths)
112
+
113
+ example = scan.jupyter[0].relative_to(content_path)
114
+ raise click.ClickException(
115
+ f"{content_path.name}/ mixes marimo and Jupyter notebooks:\n"
116
+ f" marimo: {_names(scan.marimo)}\n"
117
+ f" Jupyter: {_names(scan.jupyter)}\n"
118
+ " A content folder must use one or the other. Convert the .ipynb "
119
+ f"(marimo convert {example} -o {example.with_suffix('.py')}) "
120
+ "or move it to another content folder."
121
+ )
122
+
123
+
124
+ def _remove_orphans(scan: _FolderScan):
125
+ """Delete generated notebooks whose marimo source no longer exists."""
126
+ marimo = set(scan.marimo)
127
+ for notebook_path in scan.generated:
128
+ source = notebook_path.parent / (_generated_from(notebook_path) or "")
129
+ if source not in marimo:
130
+ notebook_path.unlink()
131
+ click.echo(f"Removed {notebook_path.name} (its marimo source is gone)")
132
+
133
+
134
+ def _sync_gitignore(content_path: Path, has_marimo: bool):
135
+ """Keep `*.ipynb` (and `__marimo__/`) in the folder's .gitignore exactly
136
+ while it holds marimo notebooks. Only the block afterpython wrote, starting
137
+ at GITIGNORE_MARKER, is ever added or removed."""
138
+ gitignore = content_path / ".gitignore"
139
+ text = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
140
+ lines = text.splitlines()
141
+ has_ours = GITIGNORE_MARKER in lines
142
+
143
+ if has_marimo and not has_ours:
144
+ existing = {line.strip() for line in lines}
145
+ missing = [p for p in GITIGNORE_PATTERNS if p not in existing]
146
+ if not missing:
147
+ return # the user already ignores these themselves
148
+ block = "\n".join([GITIGNORE_MARKER, *missing]) + "\n"
149
+ sep = "" if not text or text.endswith("\n") else "\n"
150
+ gitignore.write_text(text + sep + ("\n" if text else "") + block, "utf-8")
151
+ click.echo(
152
+ click.style(
153
+ f"⚠ {content_path.name}/ contains marimo notebooks: "
154
+ f"{', '.join(missing)} added to {content_path.name}/.gitignore\n"
155
+ " (.ipynb files here are generated from .py and not committed)",
156
+ fg="yellow",
157
+ )
158
+ )
159
+ elif not has_marimo and has_ours:
160
+ start = lines.index(GITIGNORE_MARKER)
161
+ end = start + 1
162
+ while end < len(lines) and lines[end].strip() in GITIGNORE_PATTERNS:
163
+ end += 1
164
+ kept = lines[:start] + lines[end:]
165
+ while kept and not kept[-1].strip():
166
+ kept.pop()
167
+ gitignore.write_text("\n".join(kept) + "\n" if kept else "", "utf-8")
168
+ click.echo(
169
+ f"{content_path.name}/ has no marimo notebooks anymore: removed "
170
+ f"afterpython's ignore lines from {content_path.name}/.gitignore"
171
+ )
172
+
173
+
174
+ def _marimo_version() -> str:
175
+ if shutil.which("marimo") is None:
176
+ raise click.ClickException(
177
+ "marimo is required to build marimo notebooks in content folders, "
178
+ 'install it with: uv add --dev "afterpython[marimo]"'
179
+ )
180
+ result = subprocess.run(
181
+ ["marimo", "--version"], capture_output=True, text=True, check=False
182
+ )
183
+ return result.stdout.strip()
184
+
185
+
186
+ def _source_hash(source: Path) -> str:
187
+ return hashlib.sha256(source.read_bytes()).hexdigest()
188
+
189
+
190
+ def _is_cached(source: Path, output: Path, marimo_version: str) -> bool:
191
+ try:
192
+ notebook = json.loads(output.read_text(encoding="utf-8"))
193
+ stamp = notebook["metadata"]["afterpython"]
194
+ except (OSError, ValueError, KeyError, TypeError):
195
+ return False
196
+ return (
197
+ stamp.get("source_hash") == _source_hash(source)
198
+ and stamp.get("marimo_version") == marimo_version
199
+ )
200
+
201
+
202
+ def _as_lines(source: str | list[str]) -> list[str]:
203
+ text = source if isinstance(source, str) else "".join(source)
204
+ return text.splitlines(keepends=True)
205
+
206
+
207
+ def _parse_frontmatter(cell: dict) -> dict | None:
208
+ """The YAML mapping if a markdown cell is a `---`-fenced frontmatter block."""
209
+ if cell.get("cell_type") != "markdown":
210
+ return None
211
+ text = "".join(cell.get("source", [])).strip()
212
+ match = re.match(r"^---\s*\n(.*?)\n---\s*(\n|$)", text, re.DOTALL)
213
+ if not match:
214
+ return None
215
+ try:
216
+ data = YAML(typ="safe").load(match.group(1))
217
+ except YAMLError:
218
+ return None
219
+ return data if isinstance(data, dict) and data else None
220
+
221
+
222
+ def _check_frontmatter_first(notebook: dict, source: Path):
223
+ """MyST only reads frontmatter from the first cell; anywhere else it renders
224
+ as text (the YAML becomes a garbled page title)."""
225
+ for index, cell in enumerate(notebook["cells"]):
226
+ if index > 0 and _parse_frontmatter(cell) is not None:
227
+ raise click.ClickException(
228
+ f"{source}: the frontmatter cell (--- ... ---) must be the first "
229
+ "cell of the notebook, MyST ignores it anywhere else.\n"
230
+ " Move that mo.md(...) cell to the top of the file."
231
+ )
232
+
233
+
234
+ def _project_import_name() -> str | None:
235
+ from afterpython.tools.pyproject import read_pyproject
236
+
237
+ # only the name is needed, so skip read_metadata()'s full validation
238
+ try:
239
+ name = str(read_pyproject()["project"]["name"])
240
+ except Exception:
241
+ return None
242
+ return name.replace("-", "_").replace(".", "_").lower()
243
+
244
+
245
+ def _check_no_errors(notebook: dict, source: Path):
246
+ errors = [
247
+ output
248
+ for cell in notebook["cells"]
249
+ for output in cell.get("outputs", [])
250
+ if output.get("output_type") == "error"
251
+ ]
252
+ if not errors:
253
+ return
254
+ first = errors[0]
255
+ message = (
256
+ f"{source}: {len(errors)} cell(s) raised an error while running in the "
257
+ f"notebook's sandbox, first one:\n"
258
+ f" {first.get('ename')}: {first.get('evalue')}"
259
+ )
260
+ package = _project_import_name()
261
+ if (
262
+ package
263
+ and first.get("ename") == "ModuleNotFoundError"
264
+ and f"'{package}'" in str(first.get("evalue"))
265
+ ):
266
+ project_path = os.path.relpath(ap.paths.user_path, source.parent)
267
+ message += (
268
+ "\n The notebook runs in its own environment, so your package must "
269
+ "be declared in its PEP 723 header, e.g.:\n"
270
+ " # /// script\n"
271
+ f' # dependencies = ["marimo", "{package}"]\n'
272
+ " # [tool.uv.sources]\n"
273
+ f' # {package} = {{ path = "{project_path}", editable = true }}\n'
274
+ " # ///"
275
+ )
276
+ raise click.ClickException(message)
277
+
278
+
279
+ def _cell_id_prefix(source: Path, content_path: Path) -> str:
280
+ rel = source.relative_to(content_path).with_suffix("").as_posix()
281
+ return re.sub(r"[^A-Za-z0-9_-]", "-", rel)
282
+
283
+
284
+ def _rewrite_cell_ids(notebook: dict, prefix: str):
285
+ """marimo derives cell ids from cell position, so every notebook repeats the
286
+ same ids and MyST reports duplicate identifiers across the project."""
287
+ for cell in notebook["cells"]:
288
+ cell_id = f"{prefix}-{cell.get('id', '')}"
289
+ if len(cell_id) > _MAX_CELL_ID_LENGTH:
290
+ digest = hashlib.sha1(cell_id.encode()).hexdigest()[:8]
291
+ cell_id = f"{cell_id[: _MAX_CELL_ID_LENGTH - 9]}-{digest}"
292
+ cell["id"] = cell_id
293
+
294
+
295
+ def _postprocess(
296
+ notebook: dict, source: Path, content_path: Path, marimo_version: str
297
+ ) -> dict:
298
+ for cell in notebook["cells"]:
299
+ cell["source"] = _as_lines(cell.get("source", ""))
300
+ _check_no_errors(notebook, source)
301
+ _check_frontmatter_first(notebook, source)
302
+ # outputs come from the sandbox, `myst build --execute` must not redo them
303
+ _add_fields_to_frontmatter(notebook, {"execute": "{skip: true}"})
304
+ _rewrite_cell_ids(notebook, _cell_id_prefix(source, content_path))
305
+ notebook.setdefault("metadata", {})["afterpython"] = {
306
+ "generated_from": source.name,
307
+ "source_hash": _source_hash(source),
308
+ "marimo_version": marimo_version,
309
+ }
310
+ return notebook
311
+
312
+
313
+ def convert_marimo_notebook(
314
+ source: Path, content_path: Path, marimo_version: str, use_cache: bool = True
315
+ ) -> bool:
316
+ """Export one marimo notebook to `<stem>.ipynb` next to it.
317
+
318
+ Returns True if it was (re-)exported, False if the cached output was reused.
319
+ """
320
+ output = source.with_suffix(".ipynb")
321
+ if use_cache and _is_cached(source, output, marimo_version):
322
+ return False
323
+
324
+ click.echo(f"Running marimo notebook {source.relative_to(ap.paths.user_path)}...")
325
+ with tempfile.TemporaryDirectory() as tmp:
326
+ tmp_output = Path(tmp) / output.name
327
+ result = subprocess.run(
328
+ [
329
+ "marimo",
330
+ "export",
331
+ "ipynb",
332
+ str(source),
333
+ "-o",
334
+ str(tmp_output),
335
+ "--include-outputs",
336
+ "--sandbox",
337
+ # keep the author's cell order, not marimo's dependency order
338
+ "--sort",
339
+ "top-down",
340
+ "--force",
341
+ ],
342
+ check=False,
343
+ )
344
+ # marimo (>=0.25) exits 1 when a cell fails but still writes the
345
+ # notebook, so read it whenever it exists: _postprocess reports the
346
+ # actual cell error, which is far more useful than the exit code
347
+ if not tmp_output.exists():
348
+ raise click.ClickException(f"marimo export failed for {source}")
349
+ notebook = json.loads(tmp_output.read_text(encoding="utf-8"))
350
+
351
+ notebook = _postprocess(notebook, source, content_path, marimo_version)
352
+ if result.returncode != 0:
353
+ raise click.ClickException(f"marimo export failed for {source}")
354
+ output.write_text(
355
+ json.dumps(notebook, indent=1, ensure_ascii=False) + "\n", encoding="utf-8"
356
+ )
357
+ return True
358
+
359
+
360
+ def _prepare_folder(content_path: Path) -> list[Path]:
361
+ """Folder-level rules; returns the folder's marimo notebooks."""
362
+ scan = _scan_folder(content_path)
363
+ _check_not_mixed(content_path, scan)
364
+ _remove_orphans(scan)
365
+ _sync_gitignore(content_path, has_marimo=bool(scan.marimo))
366
+ return scan.marimo
367
+
368
+
369
+ def build_marimo_content(use_cache: bool = True):
370
+ """Convert every marimo notebook in the content folders to `.ipynb`.
371
+
372
+ Args:
373
+ use_cache: reuse a generated notebook when its `.py` and the marimo
374
+ version are unchanged. `ap build` passes False: a notebook's
375
+ outputs can change without its `.py` changing (e.g. it imports the
376
+ project's own package), so production builds always re-run.
377
+ """
378
+ marimo_version: str | None = None
379
+ for content_type in sorted(CONTENT_TYPES):
380
+ content_path = ap.paths.afterpython_path / content_type
381
+ if not content_path.exists():
382
+ continue
383
+ notebooks = _prepare_folder(content_path)
384
+ if not notebooks:
385
+ continue
386
+ marimo_version = marimo_version or _marimo_version()
387
+ for source in notebooks:
388
+ convert_marimo_notebook(source, content_path, marimo_version, use_cache)
389
+
390
+
391
+ def remove_generated_notebooks(content_path: Path) -> int:
392
+ """Delete the `.ipynb` files afterpython generated in a content folder."""
393
+ if not content_path.exists():
394
+ return 0
395
+ generated = _scan_folder(content_path).generated
396
+ for notebook_path in generated:
397
+ notebook_path.unlink()
398
+ return len(generated)
399
+
400
+
401
+ def watch_marimo_content(
402
+ content_types: set[str], stop_event: threading.Event, interval: float = 1.0
403
+ ):
404
+ """`ap dev`: re-export marimo notebooks when their `.py` is saved.
405
+
406
+ Best effort: every save re-runs the whole notebook, so errors are printed
407
+ instead of stopping the dev servers, and slow re-runs print a hint.
408
+ """
409
+ content_paths = [
410
+ ap.paths.afterpython_path / content_type
411
+ for content_type in sorted(content_types)
412
+ ]
413
+
414
+ def _snapshot() -> dict[Path, float]:
415
+ mtimes = {}
416
+ for content_path in content_paths:
417
+ if not content_path.exists():
418
+ continue
419
+ for path in content_path.rglob("*.py"):
420
+ rel_parts = path.relative_to(content_path).parts
421
+ if "_build" in rel_parts or any(p.startswith(".") for p in rel_parts):
422
+ continue
423
+ try:
424
+ mtimes[path] = path.stat().st_mtime
425
+ except OSError:
426
+ continue
427
+ return mtimes
428
+
429
+ marimo_version: str | None = None
430
+ seen = _snapshot()
431
+ while not stop_event.wait(interval):
432
+ current = _snapshot()
433
+ changed = {path for path, mtime in current.items() if seen.get(path) != mtime}
434
+ removed = seen.keys() - current.keys()
435
+ seen = current
436
+ if not changed and not removed:
437
+ continue
438
+ for content_path in content_paths:
439
+ if not content_path.exists():
440
+ continue
441
+ if not any(p.is_relative_to(content_path) for p in changed | removed):
442
+ continue
443
+ try:
444
+ notebooks = _prepare_folder(content_path)
445
+ for source in notebooks:
446
+ if source not in changed:
447
+ continue
448
+ marimo_version = marimo_version or _marimo_version()
449
+ start = time.monotonic()
450
+ convert_marimo_notebook(source, content_path, marimo_version)
451
+ elapsed = time.monotonic() - start
452
+ if elapsed > SLOW_EXPORT_SECONDS:
453
+ rel = source.relative_to(ap.paths.afterpython_path)
454
+ click.echo(
455
+ click.style(
456
+ f"⚠ {rel} took {elapsed:.0f}s to re-run after "
457
+ "saving.\n"
458
+ " ap dev re-runs the whole notebook on every "
459
+ "save. If this is too slow, finish editing it in "
460
+ "`marimo edit --sandbox` first, then run ap dev.",
461
+ fg="yellow",
462
+ )
463
+ )
464
+ except click.ClickException as e:
465
+ click.echo(click.style(f"✗ {e.format_message()}", fg="red"))
466
+ except Exception as e: # keep watching: a crash here would go unnoticed
467
+ click.echo(click.style(f"✗ marimo re-export failed: {e!r}", fg="red"))
@@ -28,18 +28,19 @@ def _create_molab_url(github_url: str, content_path: Path) -> str:
28
28
  def is_marimo_notebook(path: Path) -> bool:
29
29
  """Check if a Python file is a marimo notebook.
30
30
 
31
- Looks for marimo's two reliable signature lines (`__generated_with` and
32
- `marimo.App(`) — both are emitted by marimo for any notebook file, and
33
- a plain script that just `import`s marimo won't match.
31
+ Looks for `marimo.App(`, which every notebook needs and a plain script
32
+ that just `import`s marimo won't have. `__generated_with` isn't required:
33
+ marimo writes it, but hand-written notebooks (e.g. copied from the docs)
34
+ often lack it and marimo runs them fine.
34
35
  """
35
36
  if not path.exists() or not path.is_file():
36
37
  return False
37
38
  try:
38
- # marimo's signature is at the top of the file, no need to read all
39
- head = path.read_text(encoding="utf-8", errors="ignore")[:2048]
39
+ # whole file: a long PEP 723 header can push `marimo.App(` far down
40
+ text = path.read_text(encoding="utf-8", errors="ignore")
40
41
  except OSError:
41
42
  return False
42
- return "__generated_with" in head and "marimo.App(" in head
43
+ return "marimo.App(" in text
43
44
 
44
45
 
45
46
  def _export_marimo(
@@ -22,6 +22,7 @@ from afterpython.builders import (
22
22
  build_faq_json,
23
23
  build_jupyter_notebooks,
24
24
  build_llms_txt,
25
+ build_marimo_content,
25
26
  build_marimo_readme,
26
27
  build_markdown,
27
28
  build_metadata,
@@ -84,7 +85,7 @@ def _read_api_reference() -> bool:
84
85
  return bool(website.get("api_reference", False))
85
86
 
86
87
 
87
- def prebuild():
88
+ def prebuild(dev_build: bool = False):
88
89
  def _check_initialized():
89
90
  # Check if 'ap init' has been run
90
91
  afterpython_toml = ap.paths.afterpython_path / "afterpython.toml"
@@ -141,6 +142,10 @@ def prebuild():
141
142
  build_comments_json()
142
143
  build_marimo_readme(mode=_get_readme_py_mode())
143
144
  build_markdown()
145
+ # before build_jupyter_notebooks() so generated notebooks get the same
146
+ # thumbnail/molab post-processing as hand-written ones. Production builds
147
+ # always re-run notebooks (outputs can change without the .py changing).
148
+ build_marimo_content(use_cache=dev_build)
144
149
  build_jupyter_notebooks()
145
150
 
146
151
 
@@ -173,7 +178,12 @@ def postbuild(dev_build: bool = False):
173
178
  print(f"Moved: {source} to {destination}")
174
179
 
175
180
  build_content_json()
176
- delete_placeholder_index_md_files()
181
+ # In dev, `myst start` is still running and watches myst.yml: removing the
182
+ # placeholder from the TOC now would make the first real page the index,
183
+ # so every later rebuild lands in index.json. `ap dev` deletes the
184
+ # placeholders when its servers stop instead.
185
+ if not dev_build:
186
+ delete_placeholder_index_md_files()
177
187
 
178
188
  website_static = ap.paths.website_path / "static"
179
189
  website_static.mkdir(parents=True, exist_ok=True)
@@ -269,62 +279,69 @@ def build(ctx: click.Context, execute: bool):
269
279
  return
270
280
 
271
281
  paths = ctx.obj["paths"]
272
- prebuild()
273
-
274
- # Determine BASE_PATH based on website URL configuration. Computed
275
- # before build_pdoc() so it can wire pdoc's logo-link back to the
276
- # project website root.
277
- base_path = determine_base_path()
278
- if base_path:
279
- click.echo(f"Using BASE_PATH: {base_path}")
280
- else:
281
- click.echo("Using BASE_PATH: (empty - custom domain)")
282
-
283
- if _read_api_reference():
284
- build_pdoc(base_path=base_path)
285
-
286
- node_env: NodeEnv = find_node_env()
287
-
288
- # myst's production build
289
- for content_type in CONTENT_TYPES:
290
- content_path = ap.paths.afterpython_path / content_type
291
- myst_yml_path = content_path / "myst.yml"
292
-
293
- if not content_path.exists():
294
- click.echo(f"{content_path} does not exist, skip building {content_type}/")
295
- continue
296
-
297
- if not myst_yml_path.exists():
298
- click.echo(
299
- f"No myst.yml found in {content_path}, skip building {content_type}/"
282
+ # prebuild() creates placeholder index.md files and edits myst.yml TOCs;
283
+ # remove them even if a later step fails (e.g. a marimo notebook errors)
284
+ try:
285
+ prebuild()
286
+
287
+ # Determine BASE_PATH based on website URL configuration. Computed
288
+ # before build_pdoc() so it can wire pdoc's logo-link back to the
289
+ # project website root.
290
+ base_path = determine_base_path()
291
+ if base_path:
292
+ click.echo(f"Using BASE_PATH: {base_path}")
293
+ else:
294
+ click.echo("Using BASE_PATH: (empty - custom domain)")
295
+
296
+ if _read_api_reference():
297
+ build_pdoc(base_path=base_path)
298
+
299
+ node_env: NodeEnv = find_node_env()
300
+
301
+ # myst's production build
302
+ for content_type in CONTENT_TYPES:
303
+ content_path = ap.paths.afterpython_path / content_type
304
+ myst_yml_path = content_path / "myst.yml"
305
+
306
+ if not content_path.exists():
307
+ click.echo(
308
+ f"{content_path} does not exist, skip building {content_type}/"
309
+ )
310
+ continue
311
+
312
+ if not myst_yml_path.exists():
313
+ click.echo(
314
+ f"No myst.yml found in {content_path}, skip building {content_type}/"
315
+ )
316
+ continue
317
+
318
+ if not has_content_for_myst(content_path):
319
+ click.echo(f"Skipping {content_type}/ (no content files found)")
320
+ continue
321
+
322
+ click.echo(f"Building {content_type}/...")
323
+ # NOTE: needs to set BASE_URL (used by MyST) so that the project website can link to the content pages correctly at e.g. localhost:5173/doc
324
+ base_url = f"{base_path}/{content_type}"
325
+ build_env = {**node_env, "BASE_URL": base_url, "BASE_PATH": base_path}
326
+ result = subprocess.run(
327
+ [
328
+ "myst",
329
+ "build",
330
+ "--html",
331
+ *(["--execute"] if execute else []),
332
+ *ctx.args,
333
+ ],
334
+ cwd=content_path,
335
+ env=build_env,
336
+ check=False,
300
337
  )
301
- continue
302
-
303
- if not has_content_for_myst(content_path):
304
- click.echo(f"Skipping {content_type}/ (no content files found)")
305
- continue
306
-
307
- click.echo(f"Building {content_type}/...")
308
- # NOTE: needs to set BASE_URL (used by MyST) so that the project website can link to the content pages correctly at e.g. localhost:5173/doc
309
- base_url = f"{base_path}/{content_type}"
310
- build_env = {**node_env, "BASE_URL": base_url, "BASE_PATH": base_path}
311
- result = subprocess.run(
312
- [
313
- "myst",
314
- "build",
315
- "--html",
316
- *(["--execute"] if execute else []),
317
- *ctx.args,
318
- ],
319
- cwd=content_path,
320
- env=build_env,
321
- check=False,
322
- )
323
338
 
324
- if result.returncode != 0:
325
- raise Exit(result.returncode)
339
+ if result.returncode != 0:
340
+ raise Exit(result.returncode)
326
341
 
327
- postbuild(dev_build=False)
342
+ postbuild(dev_build=False)
343
+ finally:
344
+ delete_placeholder_index_md_files()
328
345
 
329
346
  click.echo("Building project website...")
330
347
  website_env = {**node_env, "BASE_PATH": base_path}
@@ -42,10 +42,16 @@ def clean(ctx, all: bool):
42
42
  paths = ctx.obj["paths"]
43
43
  node_env: NodeEnv = find_node_env()
44
44
 
45
+ from afterpython.builders.marimo_content import remove_generated_notebooks
46
+
45
47
  # Clean MyST builds for each content type
46
48
  for content_type in CONTENT_TYPES:
47
49
  content_path = paths.afterpython_path / content_type
48
50
 
51
+ # .ipynb files generated from marimo notebooks are build artifacts too
52
+ if removed := remove_generated_notebooks(content_path):
53
+ click.echo(f" Removed {removed} generated notebook(s) in {content_type}/")
54
+
49
55
  # Skip if no content exists
50
56
  if not has_content_for_myst(content_path):
51
57
  click.echo(f"Skipping {content_type}/ (no content found)")
@@ -230,8 +230,20 @@ def dev(
230
230
  )
231
231
  enabled_content_types = {ct for ct, flag in content_flags.items() if flag}
232
232
 
233
+ stop_watching = threading.Event()
234
+
233
235
  try:
234
- prebuild()
236
+ prebuild(dev_build=True)
237
+
238
+ # re-export marimo notebooks on save for the content types being served
239
+ if enabled_content_types:
240
+ from afterpython.builders.marimo_content import watch_marimo_content
241
+
242
+ threading.Thread(
243
+ target=watch_marimo_content,
244
+ args=(enabled_content_types, stop_watching),
245
+ daemon=True,
246
+ ).start()
235
247
 
236
248
  # Clear .env.development before writing new ports
237
249
  env_file = paths.website_path / ".env.development"
@@ -313,4 +325,10 @@ def dev(
313
325
  # Handle Ctrl+C during subprocess.run
314
326
  pass
315
327
  finally:
328
+ stop_watching.set()
316
329
  cleanup_processes()
330
+ # runs on shutdown: postbuild() keeps the placeholder index.md files
331
+ # during the session (MyST servers watch myst.yml); clean them up now
332
+ from afterpython.builders import delete_placeholder_index_md_files
333
+
334
+ delete_placeholder_index_md_files()
@@ -90,7 +90,9 @@ class AliasGroup(click.Group):
90
90
  @click.version_option(version=__version__)
91
91
  def afterpython_group(ctx):
92
92
  """afterpython's CLI"""
93
- load_dotenv(find_dotenv()) # Load environment variables from .env file
93
+ # usecwd: search from the user's project, not from this file's location
94
+ # (with an editable install that would find afterpython's own .env)
95
+ load_dotenv(find_dotenv(usecwd=True))
94
96
  ctx.ensure_object(dict)
95
97
  ctx.obj["paths"] = ap.paths
96
98