afterpython 0.4.1__py3-none-any.whl → 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- afterpython/builders/__init__.py +2 -0
- afterpython/builders/jupyter_notebook.py +23 -7
- afterpython/builders/marimo_content.py +467 -0
- afterpython/builders/marimo_notebook.py +7 -6
- afterpython/cli/commands/build.py +72 -55
- afterpython/cli/commands/clean.py +6 -0
- afterpython/cli/commands/dev.py +19 -1
- afterpython/cli/main.py +3 -1
- {afterpython-0.4.1.dist-info → afterpython-0.4.2.dist-info}/METADATA +3 -2
- {afterpython-0.4.1.dist-info → afterpython-0.4.2.dist-info}/RECORD +12 -11
- {afterpython-0.4.1.dist-info → afterpython-0.4.2.dist-info}/WHEEL +0 -0
- {afterpython-0.4.1.dist-info → afterpython-0.4.2.dist-info}/entry_points.txt +0 -0
afterpython/builders/__init__.py
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
`
|
|
33
|
-
|
|
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
|
-
#
|
|
39
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
325
|
-
|
|
339
|
+
if result.returncode != 0:
|
|
340
|
+
raise Exit(result.returncode)
|
|
326
341
|
|
|
327
|
-
|
|
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)")
|
afterpython/cli/commands/dev.py
CHANGED
|
@@ -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()
|
afterpython/cli/main.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: afterpython
|
|
3
|
-
Version: 0.4.
|
|
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]
|
|
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**
|
|
@@ -3,27 +3,28 @@ afterpython/_io/toml.py,sha256=dvmq6n4MhlJ02u_YjfC3mOnCWatfCn_J1lugavigoJw,3410
|
|
|
3
3
|
afterpython/_io/yaml.py,sha256=raksve_gaIFJim8lQrq-kQHkRjTloed7CT2FY0oUJSg,654
|
|
4
4
|
afterpython/_paths.py,sha256=FTwQ7necKYuRosCyMZPfv1eqaEmGIsyqd3yXpm-WQTA,1584
|
|
5
5
|
afterpython/_typing.py,sha256=kDO25BbJCdNNkqoP9-JYC1zsOuNvzhqvSOCcam3yuAk,128
|
|
6
|
-
afterpython/builders/__init__.py,sha256=
|
|
6
|
+
afterpython/builders/__init__.py,sha256=A8LWY4ESeH3SuhkjRcwrCgIAAZXnwHk4asNGGTmVQ9g,1166
|
|
7
7
|
afterpython/builders/build_pdoc.py,sha256=lFWQzw4w_-O1PdWHst3eAbgZKkZuQXWtMBFOmZK5BlQ,2442
|
|
8
8
|
afterpython/builders/comments_json.py,sha256=7gY1wy78wqSscTIFlDhwDKNYN6ioLzpDpACLBmwB9rw,3603
|
|
9
9
|
afterpython/builders/content_json.py,sha256=yem8qhVjy5-aTnmUIING5ttNZFsuTQ-r5M_dYKGRyTk,5362
|
|
10
10
|
afterpython/builders/faq_json.py,sha256=mMfFepRBL_1QYO0tQ6orf5ESo2LVntk55nfFzencViM,1193
|
|
11
11
|
afterpython/builders/index_md.py,sha256=ZpBA1FIHgw9MXD8kRaq2OBaEAKnf94_sJmB2cG68iag,6021
|
|
12
|
-
afterpython/builders/jupyter_notebook.py,sha256=
|
|
12
|
+
afterpython/builders/jupyter_notebook.py,sha256=6PBz-jYPRMhk_1MsM8pfoyVbbvCKqSr1FIp7n8xQO8g,10580
|
|
13
13
|
afterpython/builders/llms_txt.py,sha256=9x-B1i1buTDJt4zGs8nugyto_pI5QIp7WUBcv-zpcso,9226
|
|
14
|
-
afterpython/builders/
|
|
14
|
+
afterpython/builders/marimo_content.py,sha256=uqlN96eOLYgkRmPiPqcDbaWVYwB2hBV1qVWXTbbus-U,18324
|
|
15
|
+
afterpython/builders/marimo_notebook.py,sha256=bYX65k8aKDVw3hdDI_EZQ1AXsd34q-M5YYwJchRANms,6362
|
|
15
16
|
afterpython/builders/markdown.py,sha256=0ExfRqvOTWsgUkAVaGGNkiNDtNedRr7JR1yTGxpbsmA,8736
|
|
16
17
|
afterpython/builders/metadata.py,sha256=EueKoYWM__Hb9MwX0KYejxJvouoaAxD1oz9AP-Na-ZI,3830
|
|
17
18
|
afterpython/builders/url_md.py,sha256=MfXSy0f44J72OlHASir-Xscqq1onA10vtqUO_L4b1xM,3949
|
|
18
19
|
afterpython/cli/__init__.py,sha256=G4AB-rllhmr7tURFc5cXJfkjZZ2bXtMBg3oy4zUGEFw,84
|
|
19
20
|
afterpython/cli/commands/add.py,sha256=Ppp4msdvOwN9_-fdG5-b0-IV9p0Eef9CiIEU3srUjYk,1792
|
|
20
|
-
afterpython/cli/commands/build.py,sha256=
|
|
21
|
+
afterpython/cli/commands/build.py,sha256=PhhykRYpF9Vekv8KkLnTVZ95lsL0xG61KG0OpFMwhMg,12931
|
|
21
22
|
afterpython/cli/commands/bump.py,sha256=ULrZmQuQ-oJSYXNyEyCg3VxrHDhzwOSMv37Uk1fQlgs,13354
|
|
22
23
|
afterpython/cli/commands/changelog.py,sha256=R4GdvJlnfd8joVnRRon9y-eM8RfSIxE1GCOspMrxXEw,1597
|
|
23
24
|
afterpython/cli/commands/check.py,sha256=QBt_KHlD72QJmBordcsoGKvyX4Iuj7ezjFStuNZVaOk,1694
|
|
24
|
-
afterpython/cli/commands/clean.py,sha256=
|
|
25
|
+
afterpython/cli/commands/clean.py,sha256=3KETi9-LdRdfyxwoMr59bXNaFw2NajfZIWIyu31U8JE,2841
|
|
25
26
|
afterpython/cli/commands/commit_msg.py,sha256=2PYTqsxMeYaGwQSb2MER3a73gmUoSAietuApJW0XKw4,4513
|
|
26
|
-
afterpython/cli/commands/dev.py,sha256=
|
|
27
|
+
afterpython/cli/commands/dev.py,sha256=ZOxTM_ztRM3qvoWRFBmHDKPbNiFGhdUIP1dfBFQcy1o,10937
|
|
27
28
|
afterpython/cli/commands/format.py,sha256=VFwCG9KDTF6j4Y_Ewp4_RpS8uNBQNOAj9HU_G97my5A,710
|
|
28
29
|
afterpython/cli/commands/init.py,sha256=1tECOi_okfuZLgbXnVHek2-7qwLDXbLLEUrnX8EPrU4,14504
|
|
29
30
|
afterpython/cli/commands/init_branch_rules.py,sha256=fzAm8D5qbW3u4bEMpqPuRgO-0akuCGG7pu09JlKhYn4,243
|
|
@@ -38,7 +39,7 @@ afterpython/cli/commands/serve.py,sha256=L0LIhMGByQA_mNTOAGHQTQ8TcBfi5sgmO1_iGI2
|
|
|
38
39
|
afterpython/cli/commands/start.py,sha256=edbPWsgr4m5P4w-0z12ObwkdD60HGzV5jFQlbwlbyyQ,4119
|
|
39
40
|
afterpython/cli/commands/sync.py,sha256=Rx_Wmf8toys3FjENtcKVCw22ejEvOzyuqblWKA04dl0,6467
|
|
40
41
|
afterpython/cli/commands/update.py,sha256=q_ZxbPC-4fyUPlX3Ohoz5f3cJrz5j3d5-1AH453_VTQ,9649
|
|
41
|
-
afterpython/cli/main.py,sha256=
|
|
42
|
+
afterpython/cli/main.py,sha256=MYv7NthSmRyr5AQfOBrXjng0QB3Zem6jLlSgjXi1o9I,5121
|
|
42
43
|
afterpython/const.py,sha256=R4G-rq_1sZ35aQR0eXxpNgIjbk2Aw5lvhMSKNNjiyA4,225
|
|
43
44
|
afterpython/main.py,sha256=w0z6mTib3u4p2HxMrk-tIJlZtur7uRaqsbE0ewlYlSs,490
|
|
44
45
|
afterpython/pcu.py,sha256=nfdY2uKgOxHQ1AqUN9Y-L5BxDLd7NiuZf3tt_ef2wvA,6500
|
|
@@ -65,7 +66,7 @@ afterpython/tools/prek.py,sha256=NEEGf8alTt9MIelAJ8yxWD-V4mnM_pJ7oHgVQG8pbzo,354
|
|
|
65
66
|
afterpython/tools/pyproject.py,sha256=Slhxo-Z9M7Gn9ZGL2BKmbPONpDgAJ1g33TdiulxwEyQ,3700
|
|
66
67
|
afterpython/utils.py,sha256=rbCOmRCHif2qzefw9rS-jFA05dz57hZKBr2mxVRaGR0,12406
|
|
67
68
|
afterpython/version.py,sha256=hihRV1S-gc1m46OaXD32ikkEkqzs-Gtq7AcAqOQV-wY,1603
|
|
68
|
-
afterpython-0.4.
|
|
69
|
-
afterpython-0.4.
|
|
70
|
-
afterpython-0.4.
|
|
71
|
-
afterpython-0.4.
|
|
69
|
+
afterpython-0.4.2.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
70
|
+
afterpython-0.4.2.dist-info/entry_points.txt,sha256=roa0PDgBueM1JPbRG0G2OSxzaN394OUL_oS9iEwVLBc,80
|
|
71
|
+
afterpython-0.4.2.dist-info/METADATA,sha256=kX8jUknJ_OL_frLSqJXss3m4nAt7WrahMaU7SG-67Sk,6416
|
|
72
|
+
afterpython-0.4.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|