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.
Files changed (59) hide show
  1. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/PKG-INFO +14 -9
  2. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/README.md +11 -5
  3. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/pyproject.toml +5 -5
  4. afterpython-0.1.0rc0/src/afterpython/builders/__init__.py +17 -0
  5. afterpython-0.1.0rc0/src/afterpython/builders/content_json.py +131 -0
  6. afterpython-0.1.0rc0/src/afterpython/builders/index_md.py +94 -0
  7. afterpython-0.1.0rc0/src/afterpython/builders/jupyter_notebook.py +293 -0
  8. afterpython-0.1.0rc0/src/afterpython/builders/markdown.py +271 -0
  9. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/builders/metadata.py +5 -1
  10. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/build.py +90 -62
  11. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/check.py +1 -1
  12. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/clean.py +2 -2
  13. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/commit.py +5 -2
  14. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/commitizen.py +1 -1
  15. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/dev.py +10 -7
  16. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/format.py +1 -1
  17. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/init.py +1 -1
  18. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/install.py +1 -1
  19. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/lock.py +1 -1
  20. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/pre_commit.py +1 -1
  21. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/preview.py +1 -1
  22. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/start.py +1 -1
  23. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/sync.py +33 -6
  24. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/update.py +5 -5
  25. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/ci-workflow-template.yml +1 -1
  26. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/cz-template.toml +0 -1
  27. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/pre-commit-config-template.yaml +1 -1
  28. afterpython-0.1.0rc0/src/afterpython/tools/_afterpython.py +128 -0
  29. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/myst.py +42 -3
  30. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/utils.py +73 -0
  31. afterpython-0.1.0.dev17/src/afterpython/builders/__init__.py +0 -7
  32. afterpython-0.1.0.dev17/src/afterpython/builders/molab.py +0 -100
  33. afterpython-0.1.0.dev17/src/afterpython/tools/_afterpython.py +0 -66
  34. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/__init__.py +0 -0
  35. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/_io/toml.py +0 -0
  36. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/_io/yaml.py +0 -0
  37. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/_paths.py +0 -0
  38. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/_typing.py +0 -0
  39. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/__init__.py +0 -0
  40. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/add.py +0 -0
  41. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/bump.py +0 -0
  42. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/init_branch_rules.py +0 -0
  43. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/release.py +0 -0
  44. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/commands/remove.py +0 -0
  45. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/cli/main.py +0 -0
  46. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/const.py +0 -0
  47. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/main.py +0 -0
  48. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/pcu.py +0 -0
  49. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/dependabot-template.yml +0 -0
  50. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/deploy-workflow-template.yml +0 -0
  51. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/release-workflow-template.yml +0 -0
  52. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/templates/ruff-template.toml +0 -0
  53. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/_git.py +0 -0
  54. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/branch_rules.py +0 -0
  55. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/commitizen.py +0 -0
  56. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/github_actions.py +0 -0
  57. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/pre_commit.py +0 -0
  58. {afterpython-0.1.0.dev17 → afterpython-0.1.0rc0}/src/afterpython/tools/pyproject.py +0 -0
  59. {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.0.dev17
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.6.6
25
+ Requires-Dist: mystmd>=1.7.0
27
26
  Requires-Dist: pdoc>=16.0.0
28
- Requires-Dist: ruff>=0.14.6
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.0.dev17"
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.6.6",
46
+ "mystmd>=1.7.0",
47
47
  "pdoc>=16.0.0",
48
- "ruff>=0.14.6",
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.13,<0.10.0"]
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"[![Open in molab]({_get_molab_badge()})]({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)