afterpython 0.1.0.dev17__tar.gz → 0.1.0rc0__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.
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/PKG-INFO +14 -9
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/README.md +11 -5
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/pyproject.toml +5 -5
- afterpython-0.1.0rc0/src/afterpython/builders/__init__.py +17 -0
- afterpython-0.1.0rc0/src/afterpython/builders/content_json.py +131 -0
- afterpython-0.1.0rc0/src/afterpython/builders/index_md.py +94 -0
- afterpython-0.1.0rc0/src/afterpython/builders/jupyter_notebook.py +293 -0
- afterpython-0.1.0rc0/src/afterpython/builders/markdown.py +271 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/builders/metadata.py +5 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/build.py +90 -62
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/check.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/clean.py +2 -2
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/commit.py +5 -2
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/commitizen.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/dev.py +10 -7
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/format.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/init.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/install.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/lock.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/pre_commit.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/preview.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/start.py +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/sync.py +33 -6
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/update.py +5 -5
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/ci-workflow-template.yml +1 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/cz-template.toml +0 -1
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/pre-commit-config-template.yaml +1 -1
- afterpython-0.1.0rc0/src/afterpython/tools/_afterpython.py +128 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/myst.py +42 -3
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/utils.py +73 -0
- afterpython-0.1.0.dev17/src/afterpython/builders/__init__.py +0 -7
- afterpython-0.1.0.dev17/src/afterpython/builders/molab.py +0 -100
- afterpython-0.1.0.dev17/src/afterpython/tools/_afterpython.py +0 -66
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/__init__.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/_io/toml.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/_io/yaml.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/_paths.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/_typing.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/__init__.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/add.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/bump.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/init_branch_rules.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/release.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/remove.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/main.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/const.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/main.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/pcu.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/dependabot-template.yml +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/deploy-workflow-template.yml +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/release-workflow-template.yml +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/ruff-template.toml +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/_git.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/branch_rules.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/commitizen.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/github_actions.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/pre_commit.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/pyproject.py +0 -0
- {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: afterpython
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0rc0
|
|
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
|
|
@@ -20,12 +20,11 @@ Requires-Dist: httpx>=0.28.1
|
|
|
20
20
|
Requires-Dist: tomlkit>=0.13.3
|
|
21
21
|
Requires-Dist: ruamel-yaml>=0.18.16
|
|
22
22
|
Requires-Dist: gitpython>=3.1.45
|
|
23
|
-
Requires-Dist: pygithub>=2.8.1
|
|
24
23
|
Requires-Dist: python-dotenv>=1.2.1
|
|
25
24
|
Requires-Dist: pyproject-metadata>=0.10.0
|
|
26
|
-
Requires-Dist: mystmd>=1.
|
|
25
|
+
Requires-Dist: mystmd>=1.7.0
|
|
27
26
|
Requires-Dist: pdoc>=16.0.0
|
|
28
|
-
Requires-Dist: ruff>=0.14.
|
|
27
|
+
Requires-Dist: ruff>=0.14.8
|
|
29
28
|
Requires-Dist: pre-commit>=4.5.0
|
|
30
29
|
Requires-Dist: commitizen>=4.10.0
|
|
31
30
|
Requires-Python: >=3.11
|
|
@@ -105,6 +104,17 @@ ap init
|
|
|
105
104
|
```
|
|
106
105
|
|
|
107
106
|
|
|
107
|
+
---
|
|
108
|
+
## CLI Commands
|
|
109
|
+
```bash
|
|
110
|
+
# show all commands
|
|
111
|
+
ap --help
|
|
112
|
+
|
|
113
|
+
# or use terminal UI (TUI)
|
|
114
|
+
ap tui
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
|
|
108
118
|
---
|
|
109
119
|
## Tech Stack
|
|
110
120
|
- [MyST]
|
|
@@ -119,8 +129,3 @@ ap init
|
|
|
119
129
|
- [WebLLM]
|
|
120
130
|
<!-- - ty -->
|
|
121
131
|
<!-- - [pixi] -->
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
---
|
|
125
|
-
## *Our Mission*
|
|
126
|
-
> Only by making package maintenance and documentation easy can we encourage more Python developers to publish their own packages and grow the ecosystem even further.
|
|
@@ -69,6 +69,17 @@ ap init
|
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
---
|
|
73
|
+
## CLI Commands
|
|
74
|
+
```bash
|
|
75
|
+
# show all commands
|
|
76
|
+
ap --help
|
|
77
|
+
|
|
78
|
+
# or use terminal UI (TUI)
|
|
79
|
+
ap tui
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
|
|
72
83
|
---
|
|
73
84
|
## Tech Stack
|
|
74
85
|
- [MyST]
|
|
@@ -83,8 +94,3 @@ ap init
|
|
|
83
94
|
- [WebLLM]
|
|
84
95
|
<!-- - ty -->
|
|
85
96
|
<!-- - [pixi] -->
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
---
|
|
89
|
-
## *Our Mission*
|
|
90
|
-
> Only by making package maintenance and documentation easy can we encourage more Python developers to publish their own packages and grow the ecosystem even further.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "afterpython"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.0rc0"
|
|
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 = [
|
|
@@ -38,14 +38,14 @@ dependencies = [
|
|
|
38
38
|
"tomlkit>=0.13.3",
|
|
39
39
|
"ruamel-yaml>=0.18.16",
|
|
40
40
|
"gitpython>=3.1.45",
|
|
41
|
-
"pygithub>=2.8.1",
|
|
41
|
+
# "pygithub>=2.8.1",
|
|
42
42
|
"python-dotenv>=1.2.1",
|
|
43
43
|
"pyproject-metadata>=0.10.0",
|
|
44
44
|
# "jupytext>=1.18.1",
|
|
45
45
|
# "jinja2>=3.1.6",
|
|
46
|
-
"mystmd>=1.
|
|
46
|
+
"mystmd>=1.7.0",
|
|
47
47
|
"pdoc>=16.0.0",
|
|
48
|
-
"ruff>=0.14.
|
|
48
|
+
"ruff>=0.14.8",
|
|
49
49
|
"pre-commit>=4.5.0",
|
|
50
50
|
"commitizen>=4.10.0",
|
|
51
51
|
# "python-semantic-release>=10.5.2",
|
|
@@ -65,7 +65,7 @@ pcu = "afterpython.main:run_pcu"
|
|
|
65
65
|
override-dependencies = ["platformdirs>=4.5.0", "nodeenv>=1.9.1"]
|
|
66
66
|
|
|
67
67
|
[build-system]
|
|
68
|
-
requires = ["uv_build>=0.9.
|
|
68
|
+
requires = ["uv_build>=0.9.16,<0.10.0"]
|
|
69
69
|
build-backend = "uv_build"
|
|
70
70
|
|
|
71
71
|
[tool.uv.build-backend]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from afterpython.builders.content_json import build_content_json
|
|
2
|
+
from afterpython.builders.index_md import (
|
|
3
|
+
create_placeholder_index_md_files,
|
|
4
|
+
delete_placeholder_index_md_files,
|
|
5
|
+
)
|
|
6
|
+
from afterpython.builders.jupyter_notebook import build_jupyter_notebooks
|
|
7
|
+
from afterpython.builders.markdown import build_markdown
|
|
8
|
+
from afterpython.builders.metadata import build_metadata
|
|
9
|
+
|
|
10
|
+
__all__ = (
|
|
11
|
+
"build_content_json",
|
|
12
|
+
"build_jupyter_notebooks",
|
|
13
|
+
"build_markdown",
|
|
14
|
+
"build_metadata",
|
|
15
|
+
"create_placeholder_index_md_files",
|
|
16
|
+
"delete_placeholder_index_md_files",
|
|
17
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build {content_type}.json for non-doc content type (blog, tutorial, example, guide)
|
|
3
|
+
|
|
4
|
+
Combines all individual content JSON files from MyST build output into a single
|
|
5
|
+
manifest file for SvelteKit to consume (e.g., blog.json, tutorial.json).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from afterpython._typing import tContentType
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
import afterpython as ap
|
|
21
|
+
from afterpython.const import CONTENT_TYPES
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def standardize_data(content_type: tContentType, content: dict) -> dict:
|
|
25
|
+
"""
|
|
26
|
+
Standardize the content dictionary to a single level.
|
|
27
|
+
"""
|
|
28
|
+
from afterpython.tools._afterpython import read_afterpython
|
|
29
|
+
|
|
30
|
+
afterpython = read_afterpython()
|
|
31
|
+
website = afterpython.get("website", {})
|
|
32
|
+
content_type_config = website.get(content_type, {})
|
|
33
|
+
featured_post = content_type_config.get("featured_post", "")
|
|
34
|
+
|
|
35
|
+
frontmatter = content.get("frontmatter", {})
|
|
36
|
+
# e.g. featured_post = "blog4.md" or "blog4"
|
|
37
|
+
is_featured = content["slug"] == featured_post or content["location"].endswith(
|
|
38
|
+
featured_post
|
|
39
|
+
)
|
|
40
|
+
return {
|
|
41
|
+
"version": content["version"],
|
|
42
|
+
"slug": content["slug"],
|
|
43
|
+
"location": content["location"],
|
|
44
|
+
"featured": is_featured,
|
|
45
|
+
# frontmatter fields
|
|
46
|
+
"title": frontmatter.get("title", ""),
|
|
47
|
+
"description": frontmatter.get("description", ""),
|
|
48
|
+
"tags": frontmatter.get("tags", []),
|
|
49
|
+
"date": frontmatter.get("date", ""),
|
|
50
|
+
"thumbnail": frontmatter.get("thumbnail", ""),
|
|
51
|
+
"thumbnailOptimized": frontmatter.get("thumbnailOptimized", ""),
|
|
52
|
+
"authors": frontmatter.get("authors", []),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def convert_paths(content_type: tContentType, output_file: Path):
|
|
57
|
+
"""
|
|
58
|
+
Convert paths (generated by MyST build) in the content_json file to use the new paths in the build output.
|
|
59
|
+
e.g. convert /blog_thumbnail-08d4f63a34eb7b55b6a00c25218c3295.png to /blog/build/blog_thumbnail-08d4f63a34eb7b55b6a00c25218c3295.png
|
|
60
|
+
"""
|
|
61
|
+
with open(output_file, encoding="utf-8") as f:
|
|
62
|
+
content = json.load(f)
|
|
63
|
+
|
|
64
|
+
for item in content:
|
|
65
|
+
if not item.get("thumbnailOptimized"):
|
|
66
|
+
item["thumbnailOptimized"] = item["thumbnail"]
|
|
67
|
+
item["thumbnail"] = f"/{content_type}/build" + item["thumbnail"]
|
|
68
|
+
item["thumbnailOptimized"] = (
|
|
69
|
+
f"/{content_type}/build" + item["thumbnailOptimized"]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
73
|
+
json.dump(content, f, indent=2, ensure_ascii=False)
|
|
74
|
+
|
|
75
|
+
click.echo(f"Completed path conversion in {output_file}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_content_json():
|
|
79
|
+
"""Build {content_type}.json manifest for non-doc content types.
|
|
80
|
+
|
|
81
|
+
Reads all JSON files from afterpython/_build/{content_type}/site/content/
|
|
82
|
+
(excluding index.json) and combines them into a single manifest file at
|
|
83
|
+
afterpython/_build/{content_type}.json.
|
|
84
|
+
|
|
85
|
+
This manifest is used by SvelteKit landing pages to list all content items.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
content_type: Content type (blog, tutorial, example, guide)
|
|
89
|
+
"""
|
|
90
|
+
for content_type in CONTENT_TYPES:
|
|
91
|
+
if content_type == "doc":
|
|
92
|
+
continue # Doc doesn't need a combined manifest
|
|
93
|
+
|
|
94
|
+
content_path = ap.paths.afterpython_path / content_type
|
|
95
|
+
content_dir = content_path / "_build" / "site" / "content"
|
|
96
|
+
myst_yml_path = content_path / "myst.yml"
|
|
97
|
+
if (
|
|
98
|
+
not content_path.exists()
|
|
99
|
+
or not myst_yml_path.exists()
|
|
100
|
+
or not content_dir.exists()
|
|
101
|
+
):
|
|
102
|
+
click.echo(f"Skipping {content_type}.json (missing files)")
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
click.echo(f"Building {content_type}.json...")
|
|
106
|
+
|
|
107
|
+
# Collect all JSON files except index.json, which is created by a placeholder index.md file
|
|
108
|
+
content_items = []
|
|
109
|
+
for json_file in content_dir.glob("*.json"):
|
|
110
|
+
if json_file.stem == "index":
|
|
111
|
+
continue # Skip index.json
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
with open(json_file, encoding="utf-8") as f:
|
|
115
|
+
data = json.load(f)
|
|
116
|
+
|
|
117
|
+
# Preserve all data from MyST JSON files
|
|
118
|
+
# The project-website-template will parse the fields it needs
|
|
119
|
+
content_items.append(standardize_data(content_type, data))
|
|
120
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
121
|
+
click.echo(f"Warning: Failed to parse {json_file}: {e}")
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Write combined manifest
|
|
125
|
+
output_file = ap.paths.build_path / f"{content_type}.json"
|
|
126
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
127
|
+
json.dump(content_items, f, indent=2, ensure_ascii=False)
|
|
128
|
+
|
|
129
|
+
click.echo(f"Generated {output_file} with {len(content_items)} items")
|
|
130
|
+
|
|
131
|
+
convert_paths(content_type, output_file)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
import afterpython as ap
|
|
4
|
+
from afterpython.const import CONTENT_TYPES
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_placeholder_index_md_files():
|
|
8
|
+
"""Create placeholder index.md files for non-doc content types.
|
|
9
|
+
|
|
10
|
+
Without these placeholders, MyST treats the first file in TOC as index,
|
|
11
|
+
causing it to use routes like /blog instead of /blog/blog1. These routes
|
|
12
|
+
are reserved for SvelteKit landing pages. Placeholders ensure all actual
|
|
13
|
+
content files get proper slugs and are deleted post-build.
|
|
14
|
+
"""
|
|
15
|
+
from afterpython._io.yaml import read_yaml, write_yaml
|
|
16
|
+
from afterpython.tools.myst import _write_index_file
|
|
17
|
+
|
|
18
|
+
for content_type in CONTENT_TYPES:
|
|
19
|
+
if content_type == "doc":
|
|
20
|
+
continue # Doc doesn't need a placeholder index.md
|
|
21
|
+
|
|
22
|
+
content_path = ap.paths.afterpython_path / content_type
|
|
23
|
+
if not content_path.exists():
|
|
24
|
+
click.echo(
|
|
25
|
+
f"No content found in {content_path}, skip creating placeholder index.md"
|
|
26
|
+
)
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
myst_yml_path = content_path / "myst.yml"
|
|
30
|
+
if not myst_yml_path.exists():
|
|
31
|
+
click.echo(
|
|
32
|
+
f"No myst.yml found in {content_path}, skip creating placeholder index.md"
|
|
33
|
+
)
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
content_path = ap.paths.afterpython_path / content_type
|
|
37
|
+
myst_yml_path = content_path / "myst.yml"
|
|
38
|
+
|
|
39
|
+
# Prepend index.md to TOC in myst.yml
|
|
40
|
+
if myst_yml_path.exists():
|
|
41
|
+
# Create placeholder index.md file
|
|
42
|
+
_write_index_file(content_type)
|
|
43
|
+
myst_data = read_yaml(myst_yml_path) or {}
|
|
44
|
+
toc = myst_data.get("project", {}).get("toc", [])
|
|
45
|
+
|
|
46
|
+
# Check if index.md is already first in TOC
|
|
47
|
+
if not toc or toc[0].get("file") != "index.md":
|
|
48
|
+
# Prepend index.md to TOC in myst.yml
|
|
49
|
+
new_toc = [{"file": "index.md"}, *toc]
|
|
50
|
+
if "project" not in myst_data:
|
|
51
|
+
myst_data["project"] = {}
|
|
52
|
+
myst_data["project"]["toc"] = new_toc
|
|
53
|
+
write_yaml(myst_yml_path, myst_data)
|
|
54
|
+
click.echo(f"Added index.md to TOC in {myst_yml_path}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def delete_placeholder_index_md_files():
|
|
58
|
+
"""Delete placeholder index.md and built index.html for non-doc content types.
|
|
59
|
+
|
|
60
|
+
These files were created pre-build to ensure proper slug generation. Now that
|
|
61
|
+
MyST has built the content with correct slugs, we delete them so SvelteKit
|
|
62
|
+
can own the landing page routes (/blog, /tutorial, etc.) in the project website.
|
|
63
|
+
"""
|
|
64
|
+
from afterpython._io.yaml import read_yaml, write_yaml
|
|
65
|
+
|
|
66
|
+
for content_type in CONTENT_TYPES:
|
|
67
|
+
if content_type == "doc":
|
|
68
|
+
continue # Doc doesn't need a placeholder index.md
|
|
69
|
+
|
|
70
|
+
content_path = ap.paths.afterpython_path / content_type
|
|
71
|
+
myst_yml_path = content_path / "myst.yml"
|
|
72
|
+
index_md = content_path / "index.md"
|
|
73
|
+
|
|
74
|
+
# Delete index.md from source
|
|
75
|
+
if index_md.exists():
|
|
76
|
+
index_md.unlink()
|
|
77
|
+
click.echo(f"Deleted: {index_md}")
|
|
78
|
+
|
|
79
|
+
# Delete index.html from build output
|
|
80
|
+
index_html = content_path / "_build" / "html" / "index.html"
|
|
81
|
+
if index_html.exists():
|
|
82
|
+
index_html.unlink()
|
|
83
|
+
click.echo(f"Deleted: {index_html}")
|
|
84
|
+
|
|
85
|
+
# Remove index.md from TOC in myst.yml
|
|
86
|
+
if myst_yml_path.exists():
|
|
87
|
+
myst_data = read_yaml(myst_yml_path) or {}
|
|
88
|
+
toc = myst_data.get("project", {}).get("toc", [])
|
|
89
|
+
|
|
90
|
+
# Remove index.md from TOC if it's the first entry
|
|
91
|
+
if toc and toc[0].get("file") == "index.md":
|
|
92
|
+
myst_data["project"]["toc"] = toc[1:]
|
|
93
|
+
write_yaml(myst_yml_path, myst_data)
|
|
94
|
+
click.echo(f"Removed index.md from TOC in {myst_yml_path}")
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
import afterpython as ap
|
|
8
|
+
from afterpython.const import CONTENT_TYPES
|
|
9
|
+
from afterpython.tools.pyproject import read_metadata
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_molab_badge() -> str:
|
|
13
|
+
return "https://marimo.io/molab-shield.svg"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _create_molab_url(github_url: str, content_path: Path):
|
|
17
|
+
"""Create a molab URL for a given content type and notebook path.
|
|
18
|
+
Args:
|
|
19
|
+
github_url: str, e.g. "https://github.com/AfterPythonOrg/afterpython"
|
|
20
|
+
content_path: str, e.g. "tutorial/test.ipynb"
|
|
21
|
+
"""
|
|
22
|
+
github_url = github_url.replace("https://github.com/", "github/")
|
|
23
|
+
return f"https://molab.marimo.io/{github_url}/blob/main/afterpython/{content_path.as_posix()}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_notebook(notebook_path: Path, content_path: Path) -> dict | None:
|
|
27
|
+
"""Read and validate a Jupyter notebook.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Notebook dict if valid, None if invalid or error occurred
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
with open(notebook_path) as f:
|
|
34
|
+
notebook = json.load(f)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
click.echo(f"✗ Skipping {content_path} - invalid or empty JSON")
|
|
37
|
+
return None
|
|
38
|
+
except Exception as e:
|
|
39
|
+
click.echo(f"✗ Error reading {content_path}: {e}")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# Validate notebook structure
|
|
43
|
+
if "cells" not in notebook:
|
|
44
|
+
click.echo(f"✗ Skipping {content_path} - not a valid Jupyter notebook")
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
return notebook
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _write_notebook(notebook: dict, notebook_path: Path):
|
|
51
|
+
"""Write a notebook dict to file."""
|
|
52
|
+
with open(notebook_path, "w") as f:
|
|
53
|
+
json.dump(notebook, f, indent=1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _has_frontmatter(notebook: dict) -> bool:
|
|
57
|
+
"""Check if a notebook has frontmatter in the first cell."""
|
|
58
|
+
if not notebook["cells"]:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
first_cell = notebook["cells"][0]
|
|
62
|
+
return (
|
|
63
|
+
first_cell.get("cell_type") == "markdown"
|
|
64
|
+
and first_cell.get("source")
|
|
65
|
+
and isinstance(first_cell["source"], list)
|
|
66
|
+
and len(first_cell["source"]) > 0
|
|
67
|
+
and first_cell["source"][0].strip().startswith("---")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_frontmatter(notebook: dict) -> str | None:
|
|
72
|
+
"""Read frontmatter content from a notebook.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Frontmatter content as a string if it exists, None otherwise
|
|
76
|
+
"""
|
|
77
|
+
if not _has_frontmatter(notebook):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
first_cell = notebook["cells"][0]
|
|
81
|
+
return "".join(first_cell["source"])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _write_frontmatter(notebook: dict, content: str):
|
|
85
|
+
"""Write frontmatter content to a notebook cell.
|
|
86
|
+
|
|
87
|
+
Low-level function that handles cell manipulation.
|
|
88
|
+
If frontmatter cell exists, updates it. If not, creates a new one.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
notebook: The notebook dict to modify
|
|
92
|
+
content: The complete frontmatter content as a string (should include --- delimiters)
|
|
93
|
+
"""
|
|
94
|
+
# Convert content string to list of lines
|
|
95
|
+
source_lines = [
|
|
96
|
+
line if line.endswith("\n") else line + "\n" for line in content.split("\n")
|
|
97
|
+
]
|
|
98
|
+
# Remove the last empty line if the content ends with \n
|
|
99
|
+
if source_lines and source_lines[-1] == "\n":
|
|
100
|
+
source_lines.pop()
|
|
101
|
+
|
|
102
|
+
if _has_frontmatter(notebook):
|
|
103
|
+
# Update existing frontmatter cell
|
|
104
|
+
notebook["cells"][0]["source"] = source_lines
|
|
105
|
+
else:
|
|
106
|
+
# Create new frontmatter cell
|
|
107
|
+
frontmatter_cell = {
|
|
108
|
+
"cell_type": "markdown",
|
|
109
|
+
"id": "frontmatter-cell",
|
|
110
|
+
"metadata": {},
|
|
111
|
+
"source": source_lines,
|
|
112
|
+
}
|
|
113
|
+
notebook["cells"].insert(0, frontmatter_cell)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _add_fields_to_frontmatter(notebook: dict, fields: dict[str, str]):
|
|
117
|
+
"""Add or update fields in frontmatter.
|
|
118
|
+
|
|
119
|
+
High-level function that handles frontmatter field manipulation.
|
|
120
|
+
If frontmatter exists, adds new fields to it. If not, creates new frontmatter with those fields.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
notebook: The notebook dict to modify
|
|
124
|
+
fields: A dict of fields to add/update (e.g., {"thumbnail": "null"})
|
|
125
|
+
"""
|
|
126
|
+
existing_content = _read_frontmatter(notebook)
|
|
127
|
+
|
|
128
|
+
if existing_content:
|
|
129
|
+
# Parse existing frontmatter and add new fields
|
|
130
|
+
lines = existing_content.split("\n")
|
|
131
|
+
|
|
132
|
+
# Find the closing --- of frontmatter
|
|
133
|
+
closing_index = -1
|
|
134
|
+
for i in range(1, len(lines)):
|
|
135
|
+
if lines[i].strip().startswith("---"):
|
|
136
|
+
closing_index = i
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if closing_index > 0:
|
|
140
|
+
# Insert new fields before the closing ---
|
|
141
|
+
for key, value in fields.items():
|
|
142
|
+
# Check if field already exists
|
|
143
|
+
if not any(line.strip().startswith(f"{key}:") for line in lines):
|
|
144
|
+
lines.insert(closing_index, f"{key}: {value}")
|
|
145
|
+
closing_index += 1 # Adjust index for next insertion
|
|
146
|
+
|
|
147
|
+
new_content = "\n".join(lines)
|
|
148
|
+
_write_frontmatter(notebook, new_content)
|
|
149
|
+
else:
|
|
150
|
+
# Create new frontmatter with the provided fields
|
|
151
|
+
field_lines = [f"{key}: {value}" for key, value in fields.items()]
|
|
152
|
+
new_content = "---\n" + "\n".join(field_lines) + "\n---"
|
|
153
|
+
_write_frontmatter(notebook, new_content)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _add_thumbnail_to_notebook(
|
|
157
|
+
notebook: dict, thumbnail: str, content_path: Path
|
|
158
|
+
) -> bool:
|
|
159
|
+
"""Add thumbnail field to a single notebook's frontmatter.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
notebook: The notebook dict to modify
|
|
163
|
+
thumbnail: The thumbnail value to add
|
|
164
|
+
content_path: Path for logging purposes
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
True if thumbnail was added, False if it already exists
|
|
168
|
+
"""
|
|
169
|
+
# Read frontmatter content
|
|
170
|
+
frontmatter_content = _read_frontmatter(notebook)
|
|
171
|
+
|
|
172
|
+
# Check if thumbnail is already set in frontmatter
|
|
173
|
+
if frontmatter_content and "thumbnail:" in frontmatter_content:
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
# Add thumbnail using the fields parameter
|
|
177
|
+
_add_fields_to_frontmatter(notebook, fields={"thumbnail": thumbnail})
|
|
178
|
+
|
|
179
|
+
if frontmatter_content:
|
|
180
|
+
click.echo(
|
|
181
|
+
f" ↳ Added 'thumbnail: {thumbnail}' to frontmatter in: {content_path}"
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
click.echo(
|
|
185
|
+
f" ↳ Created frontmatter with 'thumbnail: {thumbnail}' in: {content_path}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _add_molab_badge_to_notebook(notebook: dict, github_url: str, content_path: Path):
|
|
192
|
+
"""Add a molab badge markdown cell to a single notebook.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
notebook: The notebook dict to modify
|
|
196
|
+
github_url: The GitHub repository URL
|
|
197
|
+
content_path: Path relative to afterpython/ for generating the molab URL
|
|
198
|
+
|
|
199
|
+
Note:
|
|
200
|
+
The jupyter notebook needs to exist in the github repository first.
|
|
201
|
+
If you have renamed a notebook, you need to push the changes to the github repository first for the badge to work.
|
|
202
|
+
"""
|
|
203
|
+
molab_url = _create_molab_url(github_url, content_path)
|
|
204
|
+
badge_md = f"[})]({molab_url})"
|
|
205
|
+
|
|
206
|
+
# Create a markdown cell with the badge
|
|
207
|
+
badge_cell = {
|
|
208
|
+
"cell_type": "markdown",
|
|
209
|
+
"id": "molab-badge-cell", # Unique ID for the badge cell
|
|
210
|
+
"metadata": {
|
|
211
|
+
"tags": ["molab", "hide-input"] # Optional: use MyST tags
|
|
212
|
+
},
|
|
213
|
+
"source": [badge_md],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Determine insertion position (after frontmatter if it exists)
|
|
217
|
+
insert_position = 1 if _has_frontmatter(notebook) else 0
|
|
218
|
+
|
|
219
|
+
# Check if badge already exists at the expected position
|
|
220
|
+
if (
|
|
221
|
+
len(notebook["cells"]) > insert_position
|
|
222
|
+
and notebook["cells"][insert_position].get("id") == "molab-badge-cell"
|
|
223
|
+
):
|
|
224
|
+
# Update existing badge
|
|
225
|
+
notebook["cells"][insert_position] = badge_cell
|
|
226
|
+
click.echo(f"✓ Updated molab badge in: {content_path}")
|
|
227
|
+
else:
|
|
228
|
+
# Insert at the correct position
|
|
229
|
+
notebook["cells"].insert(insert_position, badge_cell)
|
|
230
|
+
click.echo(f"✓ Added molab badge to: {content_path}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def build_jupyter_notebooks():
|
|
234
|
+
"""Build Jupyter notebooks for all content types"""
|
|
235
|
+
from afterpython.tools._afterpython import get_default_thumbnail
|
|
236
|
+
|
|
237
|
+
# Check if molab badge feature is enabled
|
|
238
|
+
add_molab_badge = os.getenv("AP_MOLAB_BADGE", "1") == "1"
|
|
239
|
+
|
|
240
|
+
# Get GitHub URL once if needed for molab badges
|
|
241
|
+
github_url = None
|
|
242
|
+
if add_molab_badge:
|
|
243
|
+
metadata = read_metadata()
|
|
244
|
+
if "repository" in metadata.urls:
|
|
245
|
+
github_url = metadata.urls["repository"]
|
|
246
|
+
else:
|
|
247
|
+
click.echo(
|
|
248
|
+
"⚠ Repository URL not found in [project.urls] in pyproject.toml, skipping molab badge"
|
|
249
|
+
)
|
|
250
|
+
add_molab_badge = False
|
|
251
|
+
|
|
252
|
+
for content_type in CONTENT_TYPES:
|
|
253
|
+
click.echo(f"Building Jupyter notebooks for {content_type}/...")
|
|
254
|
+
|
|
255
|
+
path = ap.paths.afterpython_path / content_type.lower()
|
|
256
|
+
|
|
257
|
+
# Iterate over all .ipynb files once
|
|
258
|
+
for notebook_path in path.rglob("*.ipynb"):
|
|
259
|
+
# Skip files in _build directory
|
|
260
|
+
if "_build" in notebook_path.parts:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
# Get path relative to afterpython/
|
|
264
|
+
content_path = notebook_path.relative_to(ap.paths.afterpython_path)
|
|
265
|
+
|
|
266
|
+
# Get default thumbnail for this content type
|
|
267
|
+
# explicitly set to "null" if no default thumbnail is set to avoid using the first image (very likely to be "open in molab" badge image) as thumbnail
|
|
268
|
+
default_thumbnail = (
|
|
269
|
+
get_default_thumbnail(content_type, notebook_path) or "null"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Read the notebook
|
|
273
|
+
notebook = _read_notebook(notebook_path, content_path)
|
|
274
|
+
if notebook is None:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# Track if notebook was modified
|
|
278
|
+
modified = False
|
|
279
|
+
|
|
280
|
+
# Add molab badge if enabled
|
|
281
|
+
if add_molab_badge and github_url:
|
|
282
|
+
_add_molab_badge_to_notebook(notebook, github_url, content_path)
|
|
283
|
+
modified = True
|
|
284
|
+
|
|
285
|
+
# Add thumbnail to frontmatter
|
|
286
|
+
if content_type != "doc" and _add_thumbnail_to_notebook(
|
|
287
|
+
notebook, default_thumbnail, content_path
|
|
288
|
+
):
|
|
289
|
+
modified = True
|
|
290
|
+
|
|
291
|
+
# Write back if modified
|
|
292
|
+
if modified:
|
|
293
|
+
_write_notebook(notebook, notebook_path)
|