afterpython 0.0.1.dev1__py3-none-any.whl → 0.1.0__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/__init__.py +13 -0
- afterpython/_io/toml.py +105 -0
- afterpython/_io/yaml.py +26 -0
- afterpython/_paths.py +39 -0
- afterpython/_typing.py +4 -0
- afterpython/builders/__init__.py +17 -0
- afterpython/builders/content_json.py +131 -0
- afterpython/builders/index_md.py +94 -0
- afterpython/builders/jupyter_notebook.py +293 -0
- afterpython/builders/markdown.py +271 -0
- afterpython/builders/metadata.py +50 -0
- afterpython/cli/__init__.py +3 -0
- afterpython/cli/commands/add.py +67 -0
- afterpython/cli/commands/build.py +260 -0
- afterpython/cli/commands/bump.py +118 -0
- afterpython/cli/commands/check.py +36 -0
- afterpython/cli/commands/clean.py +85 -0
- afterpython/cli/commands/commit.py +52 -0
- afterpython/cli/commands/commitizen.py +32 -0
- afterpython/cli/commands/dev.py +213 -0
- afterpython/cli/commands/format.py +28 -0
- afterpython/cli/commands/init.py +107 -0
- afterpython/cli/commands/init_branch_rules.py +9 -0
- afterpython/cli/commands/install.py +28 -0
- afterpython/cli/commands/lock.py +23 -0
- afterpython/cli/commands/pre_commit.py +66 -0
- afterpython/cli/commands/preview.py +22 -0
- afterpython/cli/commands/release.py +67 -0
- afterpython/cli/commands/remove.py +67 -0
- afterpython/cli/commands/start.py +144 -0
- afterpython/cli/commands/sync.py +152 -0
- afterpython/cli/commands/update.py +176 -0
- afterpython/cli/main.py +132 -0
- afterpython/const.py +4 -0
- afterpython/main.py +25 -1
- afterpython/pcu.py +166 -0
- afterpython/templates/ci-workflow-template.yml +197 -0
- afterpython/templates/cz-template.toml +7 -0
- afterpython/templates/dependabot-template.yml +30 -0
- afterpython/templates/deploy-workflow-template.yml +88 -0
- afterpython/templates/pre-commit-config-template.yaml +48 -0
- afterpython/templates/release-workflow-template.yml +56 -0
- afterpython/templates/ruff-template.toml +32 -0
- afterpython/tools/_afterpython.py +128 -0
- afterpython/tools/_git.py +137 -0
- afterpython/tools/branch_rules.py +106 -0
- afterpython/tools/commitizen.py +13 -0
- afterpython/tools/github_actions.py +48 -0
- afterpython/tools/myst.py +208 -0
- afterpython/tools/pre_commit.py +37 -0
- afterpython/tools/pyproject.py +110 -0
- afterpython/utils.py +299 -0
- afterpython/version.py +39 -0
- afterpython-0.1.0.dist-info/METADATA +131 -0
- afterpython-0.1.0.dist-info/RECORD +57 -0
- afterpython-0.1.0.dist-info/WHEEL +4 -0
- afterpython-0.1.0.dist-info/entry_points.txt +4 -0
- afterpython/images/favicon.ico +0 -0
- afterpython/images/logo-trace.svg +0 -3
- afterpython/images/logo.png +0 -0
- afterpython/images/logo.svg +0 -2
- afterpython-0.0.1.dev1.dist-info/LICENSE +0 -201
- afterpython-0.0.1.dev1.dist-info/METADATA +0 -17
- afterpython-0.0.1.dev1.dist-info/RECORD +0 -9
- afterpython-0.0.1.dev1.dist-info/WHEEL +0 -4
|
@@ -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)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
import afterpython as ap
|
|
6
|
+
from afterpython.const import CONTENT_TYPES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _read_markdown(markdown_path: Path, content_path: Path) -> str | None:
|
|
10
|
+
"""Read and validate a markdown file.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
markdown_path: Absolute path to the markdown file
|
|
14
|
+
content_path: Path relative to afterpython/ for logging
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Markdown content as a string if valid, None if invalid or error occurred
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
with open(markdown_path, encoding="utf-8") as f:
|
|
21
|
+
content = f.read()
|
|
22
|
+
return content
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
click.echo(f"✗ Skipping {content_path} - file not found")
|
|
25
|
+
return None
|
|
26
|
+
except Exception as e:
|
|
27
|
+
click.echo(f"✗ Error reading {content_path}: {e}")
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_markdown(content: str, markdown_path: Path):
|
|
32
|
+
"""Write markdown content to file.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
content: The markdown content to write
|
|
36
|
+
markdown_path: Absolute path to the markdown file
|
|
37
|
+
"""
|
|
38
|
+
with open(markdown_path, "w", encoding="utf-8") as f:
|
|
39
|
+
f.write(content)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _has_frontmatter(content: str) -> bool:
|
|
43
|
+
"""Check if markdown content has frontmatter.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
content: The markdown file content as a string
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
True if the content starts with ---, False otherwise
|
|
50
|
+
"""
|
|
51
|
+
return content.strip().startswith("---")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_frontmatter(content: str) -> str | None:
|
|
55
|
+
"""Read frontmatter content from markdown.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
content: The markdown file content as a string
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Frontmatter content as a string (including --- delimiters) if it exists, None otherwise
|
|
62
|
+
"""
|
|
63
|
+
if not _has_frontmatter(content):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
lines = content.split("\n")
|
|
67
|
+
|
|
68
|
+
# Find the closing --- of frontmatter
|
|
69
|
+
closing_index = -1
|
|
70
|
+
for i in range(1, len(lines)):
|
|
71
|
+
if lines[i].strip() == "---":
|
|
72
|
+
closing_index = i
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
if closing_index == -1:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# Return frontmatter including both --- delimiters
|
|
79
|
+
return "\n".join(lines[: closing_index + 1])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _write_frontmatter(content: str, frontmatter: str) -> str:
|
|
83
|
+
"""Write frontmatter content to markdown.
|
|
84
|
+
|
|
85
|
+
Low-level function that handles frontmatter replacement.
|
|
86
|
+
If frontmatter exists in content, replaces it. If not, prepends it.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
content: The markdown file content as a string
|
|
90
|
+
frontmatter: The complete frontmatter content as a string (should include --- delimiters)
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The updated markdown content with the new frontmatter
|
|
94
|
+
"""
|
|
95
|
+
if _has_frontmatter(content):
|
|
96
|
+
# Replace existing frontmatter
|
|
97
|
+
lines = content.split("\n")
|
|
98
|
+
|
|
99
|
+
# Find the closing --- of existing frontmatter
|
|
100
|
+
closing_index = -1
|
|
101
|
+
for i in range(1, len(lines)):
|
|
102
|
+
if lines[i].strip() == "---":
|
|
103
|
+
closing_index = i
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
if closing_index > -1:
|
|
107
|
+
# Remove old frontmatter and prepend new one
|
|
108
|
+
body = "\n".join(lines[closing_index + 1 :])
|
|
109
|
+
return frontmatter + "\n" + body
|
|
110
|
+
else:
|
|
111
|
+
# Malformed frontmatter, just prepend new frontmatter
|
|
112
|
+
return frontmatter + "\n" + content
|
|
113
|
+
else:
|
|
114
|
+
# No frontmatter exists, prepend it
|
|
115
|
+
return frontmatter + "\n" + content
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ensure_blank_line_after_frontmatter(content: str) -> str:
|
|
119
|
+
"""Ensure there's a blank line between frontmatter and content.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
content: The markdown content
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Content with blank line after frontmatter if frontmatter exists
|
|
126
|
+
"""
|
|
127
|
+
if not _has_frontmatter(content):
|
|
128
|
+
return content
|
|
129
|
+
|
|
130
|
+
lines = 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() == "---":
|
|
136
|
+
closing_index = i
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if closing_index == -1:
|
|
140
|
+
return content
|
|
141
|
+
|
|
142
|
+
# Check if there's already a blank line after the closing ---, and if the next line is not empty, insert a blank line
|
|
143
|
+
if closing_index + 1 < len(lines) and lines[closing_index + 1].strip() != "":
|
|
144
|
+
lines.insert(closing_index + 1, "")
|
|
145
|
+
|
|
146
|
+
return "\n".join(lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _add_fields_to_frontmatter(content: str, fields: dict[str, str]) -> str:
|
|
150
|
+
"""Add or update fields in frontmatter.
|
|
151
|
+
|
|
152
|
+
High-level function that handles frontmatter field manipulation.
|
|
153
|
+
If frontmatter exists, adds new fields to it. If not, creates new frontmatter with those fields.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
content: The markdown file content as a string
|
|
157
|
+
fields: A dict of fields to add/update (e.g., {"thumbnail": "null"})
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The updated markdown content with the new fields in frontmatter
|
|
161
|
+
"""
|
|
162
|
+
existing_frontmatter = _read_frontmatter(content)
|
|
163
|
+
|
|
164
|
+
if existing_frontmatter:
|
|
165
|
+
# Parse existing frontmatter and add new fields
|
|
166
|
+
lines = existing_frontmatter.split("\n")
|
|
167
|
+
|
|
168
|
+
# Find the closing --- of frontmatter
|
|
169
|
+
closing_index = -1
|
|
170
|
+
for i in range(1, len(lines)):
|
|
171
|
+
if lines[i].strip() == "---":
|
|
172
|
+
closing_index = i
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
if closing_index > 0:
|
|
176
|
+
# Insert new fields before the closing ---
|
|
177
|
+
for key, value in fields.items():
|
|
178
|
+
# Check if field already exists
|
|
179
|
+
if not any(line.strip().startswith(f"{key}:") for line in lines):
|
|
180
|
+
lines.insert(closing_index, f"{key}: {value}")
|
|
181
|
+
closing_index += 1 # Adjust index for next insertion
|
|
182
|
+
|
|
183
|
+
new_frontmatter = "\n".join(lines)
|
|
184
|
+
return _write_frontmatter(content, new_frontmatter)
|
|
185
|
+
else:
|
|
186
|
+
# Create new frontmatter with the provided fields
|
|
187
|
+
field_lines = [f"{key}: {value}" for key, value in fields.items()]
|
|
188
|
+
new_frontmatter = "---\n" + "\n".join(field_lines) + "\n---"
|
|
189
|
+
return _write_frontmatter(content, new_frontmatter)
|
|
190
|
+
|
|
191
|
+
return content
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _add_thumbnail_to_markdown(
|
|
195
|
+
content: str, thumbnail: str, content_path: Path
|
|
196
|
+
) -> tuple[str, bool]:
|
|
197
|
+
"""Add thumbnail field to a markdown file's frontmatter.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
content: The markdown content
|
|
201
|
+
thumbnail: The thumbnail value to add
|
|
202
|
+
content_path: Path for logging purposes
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Tuple of (modified_content, was_modified)
|
|
206
|
+
"""
|
|
207
|
+
# Check if thumbnail is already set in frontmatter
|
|
208
|
+
frontmatter_content = _read_frontmatter(content)
|
|
209
|
+
if frontmatter_content and "thumbnail:" in frontmatter_content:
|
|
210
|
+
return content, False
|
|
211
|
+
|
|
212
|
+
# Add thumbnail using the fields parameter
|
|
213
|
+
modified_content = _add_fields_to_frontmatter(
|
|
214
|
+
content, fields={"thumbnail": thumbnail}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if frontmatter_content:
|
|
218
|
+
click.echo(
|
|
219
|
+
f" ↳ Added 'thumbnail: {thumbnail}' to frontmatter in: {content_path}"
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
click.echo(
|
|
223
|
+
f" ↳ Created frontmatter with 'thumbnail: {thumbnail}' in: {content_path}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return modified_content, True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_markdown():
|
|
230
|
+
"""Build markdown files for all content types"""
|
|
231
|
+
from afterpython.tools._afterpython import get_default_thumbnail
|
|
232
|
+
|
|
233
|
+
for content_type in CONTENT_TYPES:
|
|
234
|
+
# Skip doc content type - docs don't need default thumbnails
|
|
235
|
+
if content_type == "doc":
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
click.echo(f"Building markdown files for {content_type}/...")
|
|
239
|
+
|
|
240
|
+
path = ap.paths.afterpython_path / content_type.lower()
|
|
241
|
+
|
|
242
|
+
# Iterate over all .md files
|
|
243
|
+
for markdown_path in path.rglob("*.md"):
|
|
244
|
+
# Skip files in _build directory
|
|
245
|
+
if "_build" in markdown_path.parts:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Get path relative to afterpython/
|
|
249
|
+
content_path = markdown_path.relative_to(ap.paths.afterpython_path)
|
|
250
|
+
|
|
251
|
+
# Get default thumbnail for this content type
|
|
252
|
+
default_thumbnail = get_default_thumbnail(content_type, markdown_path)
|
|
253
|
+
|
|
254
|
+
# Read the markdown file
|
|
255
|
+
content = _read_markdown(markdown_path, content_path)
|
|
256
|
+
if content is None:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# Ensure blank line after frontmatter (fixes existing files)
|
|
260
|
+
original_content = content
|
|
261
|
+
content = _ensure_blank_line_after_frontmatter(content)
|
|
262
|
+
blank_line_added = content != original_content
|
|
263
|
+
|
|
264
|
+
# Add thumbnail to frontmatter
|
|
265
|
+
modified_content, was_modified = _add_thumbnail_to_markdown(
|
|
266
|
+
content, default_thumbnail, content_path
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Write back if modified (either blank line or thumbnail)
|
|
270
|
+
if was_modified or blank_line_added:
|
|
271
|
+
_write_markdown(modified_content, markdown_path)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pyproject_metadata import StandardMetadata
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
import afterpython as ap
|
|
13
|
+
|
|
14
|
+
build_path = ap.paths.build_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def convert_paths():
|
|
18
|
+
"""
|
|
19
|
+
Convert paths in "description" field (README.md) in metadata.json to use the new paths in the build output.
|
|
20
|
+
e.g. convert ./afterpython/static/image.png to static/image.png
|
|
21
|
+
"""
|
|
22
|
+
# Read metadata.json
|
|
23
|
+
with open(build_path / "metadata.json") as f:
|
|
24
|
+
metadata = json.load(f)
|
|
25
|
+
markdown_text = metadata["description"]
|
|
26
|
+
|
|
27
|
+
# Replace with the correct paths
|
|
28
|
+
updated_markdown = markdown_text.replace("afterpython/static/", "/")
|
|
29
|
+
|
|
30
|
+
# Write back to metadata.json
|
|
31
|
+
metadata["description"] = updated_markdown
|
|
32
|
+
with open(build_path / "metadata.json", "w") as f:
|
|
33
|
+
json.dump(metadata, f, indent=2)
|
|
34
|
+
|
|
35
|
+
click.echo("Completed path conversion in metadata.json")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_metadata():
|
|
39
|
+
"""Build metadata.json using pyproject.toml"""
|
|
40
|
+
from afterpython.tools.pyproject import read_metadata
|
|
41
|
+
|
|
42
|
+
click.echo("Building metadata.json...")
|
|
43
|
+
|
|
44
|
+
metadata: StandardMetadata = read_metadata()
|
|
45
|
+
|
|
46
|
+
# Write to metadata.json
|
|
47
|
+
with open(build_path / "metadata.json", "w") as f:
|
|
48
|
+
json.dump(metadata.as_json(), f, indent=2)
|
|
49
|
+
|
|
50
|
+
convert_paths()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from click.exceptions import Exit
|
|
5
|
+
|
|
6
|
+
from afterpython.utils import has_pixi, has_uv
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command()
|
|
10
|
+
@click.option(
|
|
11
|
+
"--optional",
|
|
12
|
+
type=str,
|
|
13
|
+
default=None,
|
|
14
|
+
required=False,
|
|
15
|
+
help="Add to an optional dependency group (pixi: mapped to 'optional' feature)",
|
|
16
|
+
)
|
|
17
|
+
@click.option(
|
|
18
|
+
"--group",
|
|
19
|
+
type=str,
|
|
20
|
+
default=None,
|
|
21
|
+
required=False,
|
|
22
|
+
help="Add to a dependency group (pixi: mapped to same-named feature)",
|
|
23
|
+
)
|
|
24
|
+
@click.argument("lib", type=str, required=True)
|
|
25
|
+
def add(optional: str | None, group: str | None, lib: str):
|
|
26
|
+
"""Add a new dependency to the project (manages both uv and pixi if present)"""
|
|
27
|
+
if not has_uv():
|
|
28
|
+
click.echo("uv not found. Please install uv first.")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
if optional and group:
|
|
32
|
+
click.echo("Error: Cannot specify both --optional and --group")
|
|
33
|
+
raise Exit(1)
|
|
34
|
+
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
[
|
|
37
|
+
"uv",
|
|
38
|
+
"add",
|
|
39
|
+
*(["--optional", optional] if optional else []),
|
|
40
|
+
*(["--group", group] if group else []),
|
|
41
|
+
lib,
|
|
42
|
+
],
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
if result.returncode != 0:
|
|
46
|
+
raise Exit(result.returncode)
|
|
47
|
+
|
|
48
|
+
if has_pixi():
|
|
49
|
+
# NOTE: pixi doesn't support --optional, so we need to use --feature instead
|
|
50
|
+
if optional:
|
|
51
|
+
feature_name = "optional"
|
|
52
|
+
elif group:
|
|
53
|
+
feature_name = group
|
|
54
|
+
else:
|
|
55
|
+
feature_name = None
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
[
|
|
58
|
+
"pixi",
|
|
59
|
+
"add",
|
|
60
|
+
"--pypi",
|
|
61
|
+
*(["--feature", feature_name] if feature_name else []),
|
|
62
|
+
lib,
|
|
63
|
+
],
|
|
64
|
+
check=False,
|
|
65
|
+
)
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
raise Exit(result.returncode)
|